File size: 6,284 Bytes
07fcdfe | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 | """Fetch drug SMILES from ChEMBL by ChEMBL ID.
Downloads SMILES for SciPlex3 drugs from the ChEMBL database via their
REST API. Results are cached to a CSV file for reuse.
Usage:
python scripts/fetch_chembl_smiles.py \
--chembl_ids CHEMBL3544932 CHEMBL1257042 \
--output data/chembl_smiles.csv
# Or auto-detect from SciPlex3 h5ad:
python scripts/fetch_chembl_smiles.py \
--h5ad data/raw/scPerturb/rna_protein/SrivatsanTrapnell2020_sciplex3.h5ad \
--output data/chembl_smiles.csv
"""
import argparse
import csv
import logging
import time
from pathlib import Path
import anndata as ad
import numpy as np
import pandas as pd
import requests
logger = logging.getLogger(__name__)
CHUNK_SIZE = 50
RATE_LIMIT_DELAY = 0.5 # seconds between API calls
def fetch_smiles_from_chembl(chembl_id: str) -> str:
"""Fetch canonical SMILES for a ChEMBL compound.
Parameters
----------
chembl_id : str
ChEMBL compound ID (e.g. "CHEMBL3544932")
Returns
-------
smiles : str
Canonical SMILES string, or empty string if not found.
"""
url = f"https://www.ebi.ac.uk/chembl/api/data/molecule/{chembl_id}.json"
try:
resp = requests.get(url, timeout=30)
resp.raise_for_status()
data = resp.json()
# ChEMBL API returns flat structure (not nested under 'molecule')
structures = data.get("molecule_structures", {})
smiles = structures.get("canonical_smiles", "")
return smiles if smiles else ""
except Exception as e:
logger.warning("Failed to fetch %s: %s", chembl_id, e)
return ""
def fetch_smiles_batch(chembl_ids: list[str], cache_path: Path) -> dict[str, str]:
"""Fetch SMILES for a list of ChEMBL IDs, using cache.
Parameters
----------
chembl_ids : list of str
cache_path : Path
CSV file for caching results.
Returns
-------
smiles_dict : dict mapping chembl_id → SMILES
"""
# Load existing cache
smiles_dict: dict[str, str] = {}
if cache_path.exists():
with open(cache_path) as f:
reader = csv.DictReader(f)
for row in reader:
smiles_dict[row["chembl_id"]] = row["smiles"]
logger.info("Loaded %d entries from cache %s", len(smiles_dict), cache_path)
# Find IDs not in cache
to_fetch = [cid for cid in chembl_ids if cid not in smiles_dict or not smiles_dict[cid]]
logger.info("Need to fetch %d / %d ChEMBL IDs", len(to_fetch), len(chembl_ids))
# Fetch in chunks
for i in range(0, len(to_fetch), CHUNK_SIZE):
chunk = to_fetch[i:i + CHUNK_SIZE]
for chembl_id in chunk:
smiles = fetch_smiles_from_chembl(chembl_id)
smiles_dict[chembl_id] = smiles
time.sleep(RATE_LIMIT_DELAY)
# Save cache after each chunk
_save_cache(smiles_dict, cache_path)
logger.info(" Fetched %d/%d", min(i + CHUNK_SIZE, len(to_fetch)), len(to_fetch))
return smiles_dict
def _save_cache(smiles_dict: dict[str, str], cache_path: Path) -> None:
"""Save SMILES dictionary to CSV."""
cache_path.parent.mkdir(parents=True, exist_ok=True)
with open(cache_path, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=["chembl_id", "smiles"])
writer.writeheader()
for cid, smiles in sorted(smiles_dict.items()):
writer.writerow({"chembl_id": cid, "smiles": smiles})
def extract_chembl_ids_from_h5ad(h5ad_path: str) -> list[str]:
"""Extract unique ChEMBL IDs from SciPlex3 h5ad file."""
adata = ad.read_h5ad(h5ad_path)
chembl_ids = adata.obs["chembl-ID"].dropna().unique().tolist()
# Clean up (some have multiple IDs separated by ;)
all_ids = []
for cid in chembl_ids:
for part in str(cid).split(";"):
part = part.strip()
if part.startswith("CHEMBL"):
all_ids.append(part)
return sorted(set(all_ids))
def build_drug_name_to_smiles(
h5ad_path: str, smiles_csv: Path
) -> dict[str, str]:
"""Build drug_name → SMILES mapping from SciPlex3 h5ad + ChEMBL cache.
Parameters
----------
h5ad_path : str
smiles_csv : Path
CSV with columns chembl_id, smiles
Returns
-------
drug_smiles : dict mapping drug_name → SMILES
"""
adata = ad.read_h5ad(h5ad_path)
# Load SMILES cache
smiles_by_chembl: dict[str, str] = {}
if smiles_csv.exists():
with open(smiles_csv) as f:
reader = csv.DictReader(f)
for row in reader:
smiles_by_chembl[row["chembl_id"]] = row["smiles"]
# Map drug names to SMILES
drug_smiles: dict[str, str] = {}
for _, row in adata.obs[["perturbation", "chembl-ID"]].drop_duplicates().iterrows():
drug_name = row["perturbation"]
chembl_id = row["chembl-ID"]
if pd.isna(chembl_id):
continue
# Handle multiple IDs (take first)
chembl_id = str(chembl_id).split(";")[0].strip()
if chembl_id in smiles_by_chembl and smiles_by_chembl[chembl_id]:
drug_smiles[drug_name] = smiles_by_chembl[chembl_id]
return drug_smiles
def main():
parser = argparse.ArgumentParser(description="Fetch ChEMBL SMILES for drug encoder")
parser.add_argument("--chembl_ids", nargs="*", help="ChEMBL IDs to fetch")
parser.add_argument("--h5ad", help="SciPlex3 h5ad path (auto-extract ChEMBL IDs)")
parser.add_argument("--output", required=True, help="Output CSV path")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO)
cache_path = Path(args.output)
if args.h5ad:
logger.info("Extracting ChEMBL IDs from %s", args.h5ad)
chembl_ids = extract_chembl_ids_from_h5ad(args.h5ad)
logger.info("Found %d unique ChEMBL IDs", len(chembl_ids))
elif args.chembl_ids:
chembl_ids = args.chembl_ids
else:
parser.error("Provide --chembl_ids or --h5ad")
smiles_dict = fetch_smiles_batch(chembl_ids, cache_path)
# Stats
found = sum(1 for v in smiles_dict.values() if v)
logger.info("Results: %d/%d SMILES found", found, len(smiles_dict))
logger.info("Saved to %s", cache_path)
if __name__ == "__main__":
main()
|