| """SciPlex3 drug perturbation dataset for population-level training. |
| |
| Loads the Srivatsan & Trapnell 2020 SciPlex3 dataset, which contains |
| single-cell RNA-seq profiles of cells treated with small molecule drugs |
| at multiple doses and time points. |
| |
| Data structure |
| -------------- |
| SciPlex3 contains: |
| - 189 drug perturbations (including vehicle control) |
| - 5 dose levels: 0, 10, 100, 1000, 10000 nM |
| - 2 time points: 24h, 72h |
| - 3 cell lines: MCF7 (breast), A549 (lung), K562 (CML) |
| - ChEMBL IDs for 155 drugs |
| - Protein target annotations (87 target categories) |
| |
| Training pairs are constructed as: |
| source = vehicle control cells at the same dose/time/cell_line |
| target = drug-treated cells |
| |
| This ensures the source/target difference is purely the drug effect, |
| not batch or technical variation. |
| |
| Parameters |
| ---------- |
| h5ad_path : str |
| Path to SciPlex3 h5ad file. |
| n_hvg : int |
| Number of highly variable genes to select (default 2000). |
| min_cells_per_cond : int |
| Minimum cells per (drug, dose, time) condition (default 30). |
| max_source_cells : int |
| Cells sampled per source (vehicle) population (default 64). |
| max_target_cells : int |
| Cells sampled per target (drug-treated) population (default 64). |
| cell_lines : list of str, optional |
| Which cell lines to include. Default: all (MCF7, A549, K562). |
| doses : list of float, optional |
| Which doses to include. Default: all non-zero doses. |
| times : list of float, optional |
| Which time points to include. Default: all. |
| include_control : bool |
| If True, include vehicle-only conditions as training samples (no drug). |
| Default: False. |
| target_sum : float |
| Normalisation target (default 1e4). |
| seed : int |
| Random seed. |
| |
| Usage |
| ----- |
| ds = Sciplex3Dataset("SrivatsanTrapnell2020_sciplex3.h5ad") |
| sample = ds[0] |
| # source_cells: [Ns, G] vehicle control |
| # target_cells: [Nt, G] drug-treated |
| # drug_emb: [D_d] drug molecular embedding |
| # dose: scalar drug concentration |
| # perturbation: [G] drug target genes (multi-hot) |
| # chembl_id: str ChEMBL identifier |
| # target: str protein target name |
| # pathway: str pathway annotation |
| """ |
|
|
| from __future__ import annotations |
|
|
| import logging |
| import os |
| import csv |
| from typing import Any, Dict, List, Optional, Tuple |
|
|
| import numpy as np |
| import pandas as pd |
| import torch |
| from torch.utils.data import Dataset |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| class Sciplex3Dataset(Dataset): |
| """SciPlex3 drug perturbation dataset for population-level training.""" |
|
|
| def __init__( |
| self, |
| h5ad_path: str, |
| n_hvg: int = 2000, |
| min_cells_per_cond: int = 30, |
| max_source_cells: int = 64, |
| max_target_cells: int = 64, |
| cell_lines: Optional[List[str]] = None, |
| doses: Optional[List[float]] = None, |
| times: Optional[List[float]] = None, |
| include_control: bool = False, |
| target_sum: float = 1e4, |
| seed: int = 42, |
| |
| drug_encoding: str = "morgan", |
| drug_emb_dim: int = 128, |
| |
| precomputed_drug_embeddings: Optional[Dict[str, torch.Tensor]] = None, |
| |
| drug_target_mapping: Optional[Dict[str, List[str]]] = None, |
| |
| preprocessed_path: Optional[str] = None, |
| |
| target_num_genes: Optional[int] = None, |
| |
| drug_smiles_csv: str = "", |
| ) -> None: |
| super().__init__() |
| self.max_source_cells = max_source_cells |
| self.max_target_cells = max_target_cells |
| self.seed = seed |
| self.rng = np.random.default_rng(seed) |
| self.target_num_genes = target_num_genes |
| self._smiles_cache: Dict[str, str] = {} |
| self._load_smiles_cache(drug_smiles_csv) |
|
|
| |
| if preprocessed_path and os.path.exists(preprocessed_path): |
| self._load_preprocessed(preprocessed_path, drug_emb_dim, precomputed_drug_embeddings) |
| |
| map_path = preprocessed_path.replace('.pt', '_gene_map.json') |
| if os.path.exists(map_path): |
| self._load_gene_mapping(map_path) |
| return |
|
|
| print(f"[Sciplex3Dataset] Loading {os.path.basename(h5ad_path)} ...") |
| try: |
| import anndata as ad |
| except ImportError: |
| raise ImportError("pip install anndata") |
|
|
| adata = ad.read_h5ad(h5ad_path) |
| print(f" raw shape: {adata.shape}") |
|
|
| |
| |
| |
| |
| obs = adata.obs.copy() |
|
|
| |
| if cell_lines: |
| obs = obs[obs['cell_line'].isin(cell_lines)].copy() |
| print(f" Filtered to cell lines: {cell_lines} → {len(obs)} cells") |
|
|
| |
| if doses: |
| obs = obs[obs['dose_value'].isin(doses)].copy() |
| else: |
| obs = obs[obs['dose_value'] > 0].copy() |
|
|
| if times: |
| obs = obs[obs['time'].isin(times)].copy() |
|
|
| |
| keep_idx = obs.index.values |
| print(f" After filtering: {len(keep_idx)} cells") |
| print(f" Cell lines: {obs['cell_line'].value_counts().to_dict()}") |
| print(f" Doses: {sorted(obs['dose_value'].unique())}") |
| print(f" Drugs: {obs['perturbation'].nunique()}") |
|
|
| adata = adata[keep_idx].copy() |
| obs = adata.obs.copy() |
| obs['_cell_idx'] = np.arange(len(obs)) |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| import scipy.sparse as sp |
|
|
| |
| var_names_all = list(adata.var.index) if hasattr(adata, 'var') else [f"Gene_{i}" for i in range(adata.shape[1])] |
|
|
| X_sp = adata.X |
| if not sp.issparse(X_sp): |
| X_sp = sp.csr_matrix(X_sp) |
| elif not sp.isspmatrix_csr(X_sp): |
| X_sp = X_sp.tocsr() |
|
|
| |
| del adata |
|
|
| |
| row_sums = np.asarray(X_sp.sum(axis=1)).ravel().clip(min=1.0) |
| |
| scale = (target_sum / row_sums).astype(np.float32) |
| X_sp_norm = X_sp.multiply(scale[:, np.newaxis]) |
| del X_sp |
|
|
| if n_hvg > 0: |
| |
| |
| n_cells = X_sp_norm.shape[0] |
| mean_g = np.asarray(X_sp_norm.mean(axis=0)).ravel() |
| |
| X_sq = X_sp_norm.copy() |
| X_sq.data **= 2 |
| mean_sq_g = np.asarray(X_sq.mean(axis=0)).ravel() |
| del X_sq |
| var_g = mean_sq_g - mean_g ** 2 |
| n_select = min(n_hvg, X_sp_norm.shape[1]) |
| hvg_idx = np.argsort(var_g)[::-1][:n_select].copy() |
| gene_names = np.array(var_names_all)[hvg_idx] |
|
|
| |
| |
| X_sp_csc = X_sp_norm.tocsc() |
| del X_sp_norm |
| X_hvg_pre = np.asarray(X_sp_csc[:, hvg_idx].todense(), dtype=np.float32) |
| del X_sp_csc |
| else: |
| |
| gene_names = np.array(var_names_all) |
| X_hvg_pre = np.asarray(X_sp_norm.todense(), dtype=np.float32) |
| del X_sp_norm |
|
|
| |
| X_hvg = np.log1p(X_hvg_pre) |
| del X_hvg_pre |
|
|
| self.num_genes = len(gene_names) |
| self.gene_names = gene_names |
| self._gene_to_idx: Dict[str, int] = {g: i for i, g in enumerate(gene_names)} |
| print(f" Using {self.num_genes} genes") |
|
|
| |
| if target_num_genes is not None and self.num_genes != target_num_genes: |
| self._align_genes(target_num_genes) |
|
|
| |
| |
| |
| vehicle_mask = obs['perturbation'].str.lower().str.contains('vehicle', na=False) | \ |
| (obs['dose_value'] == 0) |
|
|
| |
| vehicle_obs = obs[vehicle_mask].copy() |
| drug_obs = obs[~vehicle_mask].copy() |
|
|
| |
| |
| if len(vehicle_obs) == 0: |
| logger.warning("No vehicle cells found. Using minimum dose as pseudo-vehicle.") |
| min_dose = obs['dose_value'].min() |
| vehicle_obs = obs[obs['dose_value'] == min_dose].copy() |
| drug_obs = obs[obs['dose_value'] > min_dose].copy() |
|
|
| print(f" Vehicle cells: {len(vehicle_obs)}, Drug cells: {len(drug_obs)}") |
|
|
| |
| |
| |
| |
| |
| self._drug_target_map = self._build_drug_target_map( |
| drug_obs=drug_obs, |
| external_mapping=drug_target_mapping, |
| gene_names=list(gene_names), |
| ) |
|
|
| |
| |
| |
| |
| conditions: List[Dict] = [] |
|
|
| |
| drug_groups = drug_obs.groupby(['perturbation', 'dose_value', 'time', 'cell_line']) |
|
|
| for (drug_name, dose, time, cell_line), group in drug_groups: |
| if len(group) < min_cells_per_cond: |
| continue |
|
|
| |
| vehicle_same_cl = vehicle_obs[vehicle_obs['cell_line'] == cell_line] |
| if len(vehicle_same_cl) < min_cells_per_cond: |
| |
| vehicle_same_cl = vehicle_obs |
|
|
| |
| pert_vec = self._build_perturbation_vector(drug_name, drug_target_mapping) |
|
|
| |
| chembl_id = group['chembl-ID'].iloc[0] if 'chembl-ID' in group.columns else "" |
| if pd.isna(chembl_id): |
| chembl_id = "" |
|
|
| |
| target_name = group['target'].iloc[0] if 'target' in group.columns else "" |
| pathway_l1 = group['pathway_level_1'].iloc[0] if 'pathway_level_1' in group.columns else "" |
| pathway_l2 = group['pathway_level_2'].iloc[0] if 'pathway_level_2' in group.columns else "" |
|
|
| conditions.append({ |
| "pert_name": f"{drug_name}_{dose}nM", |
| "drug_name": drug_name, |
| "cell_line": cell_line, |
| "dose": float(dose), |
| "time": float(time) if not pd.isna(time) else 24.0, |
| "drug_cell_idx": group['_cell_idx'].values, |
| "vehicle_cell_idx": vehicle_same_cl['_cell_idx'].values, |
| "pert_vec": pert_vec, |
| "chembl_id": chembl_id, |
| "target": target_name, |
| "pathway_l1": pathway_l1, |
| "pathway_l2": pathway_l2, |
| }) |
|
|
| self._conditions = conditions |
| self._X = X_hvg |
|
|
| print(f" Conditions (≥{min_cells_per_cond} cells): {len(conditions)}") |
|
|
| |
| for cond in self._conditions: |
| name = cond['drug_name'] |
| chembl = cond.get('chembl_id', '') |
| if chembl and chembl in self._smiles_cache: |
| self._smiles_cache[name] = self._smiles_cache[chembl] |
|
|
| |
| if self.target_num_genes is not None and self.num_genes != self.target_num_genes: |
| self._align_genes(self.target_num_genes) |
|
|
| |
| drug_names = list(set(c['drug_name'] for c in conditions)) |
| print(f" Unique drugs: {len(drug_names)}") |
|
|
| |
| |
| |
| self.drug_emb_dim = drug_emb_dim |
| self._drug_name_to_idx = {name: i for i, name in enumerate(sorted(drug_names))} |
|
|
| |
| |
| for cond in conditions: |
| name = cond['drug_name'] |
| chembl = cond.get('chembl_id', '') |
| if chembl and chembl in self._smiles_cache: |
| self._smiles_cache[name] = self._smiles_cache[chembl] |
|
|
| |
| self._drug_embeddings: Optional[torch.Tensor] = None |
| if precomputed_drug_embeddings is not None: |
| self._load_precomputed_embeddings(precomputed_drug_embeddings) |
| else: |
| |
| self._compute_drug_embeddings() |
|
|
| |
| def __len__(self) -> int: |
| return len(self._conditions) |
|
|
| def __getitem__(self, idx: int) -> Dict: |
| cond = self._conditions[idx] |
|
|
| |
| ns = min(self.max_source_cells, len(cond['vehicle_cell_idx'])) |
| src_idx = self.rng.choice(cond['vehicle_cell_idx'], size=ns, replace=False) |
| source_cells = torch.from_numpy(self._X[src_idx]).float() |
|
|
| |
| nt = min(self.max_target_cells, len(cond['drug_cell_idx'])) |
| tgt_idx = self.rng.choice(cond['drug_cell_idx'], size=nt, replace=False) |
| target_cells = torch.from_numpy(self._X[tgt_idx]).float() |
|
|
| perturbation = torch.from_numpy(cond['pert_vec']).float() |
| drug_smiles = self._get_drug_smiles(cond['drug_name']) |
|
|
| return { |
| "source_cells": source_cells, |
| "target_cells": target_cells, |
| "perturbation": perturbation, |
| "drug_smiles": drug_smiles, |
| "dose": torch.tensor(cond['dose'], dtype=torch.float32), |
| "cell_line": cond['cell_line'], |
| "chembl_id": cond['chembl_id'], |
| "target": cond['target'], |
| "pathway_l1": cond['pathway_l1'], |
| "pathway_l2": cond['pathway_l2'], |
| "metadata": { |
| "pert_name": cond['pert_name'], |
| "drug_name": cond['drug_name'], |
| "num_source_cells": ns, |
| "num_target_cells": nt, |
| }, |
| } |
|
|
| |
| |
| def _get_drug_smiles(self, drug_name: str) -> str: |
| """Get SMILES string for a drug by name.""" |
| |
| smiles = self._smiles_cache.get(drug_name, "") |
| if smiles: |
| return smiles |
| |
| idx = self._drug_name_to_idx.get(drug_name, 0) |
| cond = self._conditions[0] if self._conditions else {} |
| |
| for c in self._conditions: |
| if c['drug_name'] == drug_name: |
| chembl_id = c.get('chembl_id', '') |
| if chembl_id: |
| smiles = self._smiles_cache.get(chembl_id, "") |
| if smiles: |
| return smiles |
| break |
| return "" |
|
|
| def _get_drug_embedding(self, drug_name: str) -> torch.Tensor: |
| """Get pre-computed drug embedding by name (legacy, for backward compat).""" |
| if self._drug_embeddings is None: |
| device = torch.device("cpu") |
| return torch.zeros(self.drug_emb_dim, device=device) |
|
|
| idx = self._drug_name_to_idx.get(drug_name, 0) |
| return self._drug_embeddings[idx] |
|
|
| def _load_preprocessed( |
| self, |
| path: str, |
| drug_emb_dim: int = 128, |
| precomputed_drug_embeddings: Optional[Dict[str, torch.Tensor]] = None, |
| ) -> None: |
| """Load from preprocessed .pt file (much faster than h5ad).""" |
| print(f" Loading preprocessed data from {path} ...") |
| data = torch.load(path, weights_only=False) |
|
|
| self._X = data['X'].numpy() if isinstance(data['X'], torch.Tensor) else data['X'] |
| self.gene_names = data['gene_names'] |
| self.num_genes = len(self.gene_names) |
| self._gene_to_idx = {g: i for i, g in enumerate(self.gene_names)} |
| obs = data['obs'] |
|
|
| print(f" Loaded: {self._X.shape[0]} cells, {self.num_genes} genes") |
|
|
| |
| map_path = path.replace('.pt', '_gene_map.json') |
| if os.path.exists(map_path): |
| self._load_gene_mapping(map_path) |
|
|
| |
| |
| |
| vehicle_mask = obs['perturbation'].str.lower().str.contains('vehicle', na=False) | \ |
| (obs['dose_value'] == 0) |
| vehicle_obs = obs[vehicle_mask].copy() |
| drug_obs = obs[~vehicle_mask].copy() |
|
|
| if len(vehicle_obs) == 0: |
| min_dose = obs['dose_value'].min() |
| vehicle_obs = obs[obs['dose_value'] == min_dose].copy() |
| drug_obs = obs[obs['dose_value'] > min_dose].copy() |
|
|
| print(f" Vehicle cells: {len(vehicle_obs)}, Drug cells: {len(drug_obs)}") |
|
|
| |
| |
| |
| drug_target_mapping = getattr(self, '_drug_target_mapping', None) |
| |
| |
| |
| |
| self._drug_target_map = {} |
| for drug_name in drug_obs['perturbation'].unique(): |
| target_cat = drug_obs[drug_obs['perturbation'] == drug_name]['target'].iloc[0] |
| gene_targets = self._target_category_to_genes( |
| target_cat, list(self.gene_names), |
| self._gene_to_idx, getattr(self, '_symbol_to_ensembl', None) |
| ) |
| self._drug_target_map[drug_name] = gene_targets |
|
|
| conditions: List[Dict] = [] |
|
|
| drug_groups = drug_obs.groupby(['perturbation', 'dose_value', 'time', 'cell_line']) |
| for (drug_name, dose, time, cell_line), group in drug_groups: |
| if len(group) < self.max_source_cells: |
| continue |
|
|
| vehicle_same_cl = vehicle_obs[vehicle_obs['cell_line'] == cell_line] |
| if len(vehicle_same_cl) < self.max_source_cells: |
| vehicle_same_cl = vehicle_obs |
|
|
| pert_vec = self._build_perturbation_vector(drug_name, None) |
| chembl_id = group['chembl-ID'].iloc[0] if 'chembl-ID' in group.columns else "" |
| if pd.isna(chembl_id): |
| chembl_id = "" |
| target_name = group['target'].iloc[0] if 'target' in group.columns else "" |
| pathway_l1 = group['pathway_level_1'].iloc[0] if 'pathway_level_1' in group.columns else "" |
| pathway_l2 = group['pathway_level_2'].iloc[0] if 'pathway_level_2' in group.columns else "" |
|
|
| conditions.append({ |
| "pert_name": f"{drug_name}_{dose}nM", |
| "drug_name": drug_name, |
| "cell_line": cell_line, |
| "dose": float(dose), |
| "time": float(time) if not pd.isna(time) else 24.0, |
| "drug_cell_idx": group['_cell_idx'].values, |
| "vehicle_cell_idx": vehicle_same_cl['_cell_idx'].values, |
| "pert_vec": pert_vec, |
| "chembl_id": chembl_id, |
| "target": target_name, |
| "pathway_l1": pathway_l1, |
| "pathway_l2": pathway_l2, |
| }) |
|
|
| self._conditions = conditions |
| print(f" Conditions: {len(conditions)}") |
|
|
| |
| for cond in self._conditions: |
| name = cond['drug_name'] |
| chembl = cond.get('chembl_id', '') |
| if chembl and chembl in self._smiles_cache: |
| self._smiles_cache[name] = self._smiles_cache[chembl] |
|
|
| |
| if self.target_num_genes is not None and self.num_genes != self.target_num_genes: |
| self._align_genes(self.target_num_genes) |
|
|
| drug_names = list(set(c['drug_name'] for c in conditions)) |
| print(f" Unique drugs: {len(drug_names)}") |
|
|
| |
| self.drug_emb_dim = drug_emb_dim |
| self._drug_name_to_idx = {name: i for i, name in enumerate(sorted(drug_names))} |
| |
| self._drug_embeddings: Optional[torch.Tensor] = None |
| if precomputed_drug_embeddings is not None: |
| self._load_precomputed_embeddings(precomputed_drug_embeddings) |
| else: |
| self._compute_drug_embeddings() |
|
|
| def _compute_drug_embeddings(self) -> None: |
| """Compute Morgan fingerprint embeddings for all unique drugs.""" |
| drug_names = sorted(self._drug_name_to_idx.keys()) |
| n_drugs = len(drug_names) |
| self._drug_embeddings = torch.zeros(n_drugs, self.drug_emb_dim) |
|
|
| |
| name_to_chembl = self._build_name_chembl_map() |
|
|
| try: |
| from rdkit import Chem |
| from rdkit.Chem import AllChem |
| |
| Chem.MolFromSmiles("C") |
| except (ImportError, AttributeError, Exception) as e: |
| logger.warning("RDKit not available (%s) — drug embeddings will be zero vectors.", e) |
| return |
|
|
| success_count = 0 |
| for i, name in enumerate(drug_names): |
| smiles = "" |
|
|
| |
| chembl_id = name_to_chembl.get(name, "") |
| if chembl_id: |
| smiles = self._smiles_cache.get(chembl_id, "") |
|
|
| |
| if not smiles: |
| smiles = name |
|
|
| mol = Chem.MolFromSmiles(smiles) |
| if mol is None: |
| if chembl_id: |
| logger.warning("No SMILES for drug '%s' (ChEMBL: %s, tried: %s)", name, chembl_id, smiles[:40] if smiles else "") |
| continue |
|
|
| fp = AllChem.GetMorganFingerprintAsBitVect(mol, radius=2, nBits=2048) |
| fp_arr = np.array(list(fp), dtype=np.float32) |
|
|
| |
| fp_tensor = torch.from_numpy(fp_arr).float() |
| hidden = torch.nn.functional.linear( |
| fp_tensor, |
| torch.randn(512, 2048) * 0.01, |
| ) |
| hidden = torch.nn.functional.gelu(hidden) |
| emb = torch.nn.functional.linear( |
| hidden, |
| torch.randn(self.drug_emb_dim, 512) * 0.01, |
| ) |
| self._drug_embeddings[i] = emb.detach() |
| success_count += 1 |
|
|
| print(f" Pre-computed drug embeddings: {self._drug_embeddings.shape} " |
| f"({success_count}/{n_drugs} drugs with fingerprints)") |
|
|
| def _load_precomputed_embeddings(self, embeddings: Dict[str, torch.Tensor]) -> None: |
| """Load pre-computed drug embeddings from a dictionary.""" |
| drug_names = sorted(self._drug_name_to_idx.keys()) |
| n_drugs = len(drug_names) |
| self._drug_embeddings = torch.zeros(n_drugs, self.drug_emb_dim) |
|
|
| for i, name in enumerate(drug_names): |
| if name in embeddings: |
| emb = embeddings[name] |
| if emb.dim() == 1: |
| emb = emb[:self.drug_emb_dim] |
| elif emb.dim() == 2: |
| emb = emb[0, :self.drug_emb_dim] |
| self._drug_embeddings[i] = emb |
|
|
| print(f" Loaded pre-computed drug embeddings: {self._drug_embeddings.shape}") |
|
|
| |
| def set_smiles(self, smiles_dict: Dict[str, str]) -> None: |
| """Update SMILES for drugs and recompute embeddings. |
| |
| Parameters |
| ---------- |
| smiles_dict : dict mapping drug_name → SMILES string |
| """ |
| self._smiles_cache.update(smiles_dict) |
| self._compute_drug_embeddings() |
|
|
| |
| def _load_gene_mapping(self, map_path: str) -> None: |
| """Load ENSEMBL→gene symbol mapping from JSON file.""" |
| import json |
| try: |
| with open(map_path) as f: |
| mapping = json.load(f) |
| |
| self._symbol_to_ensembl: Dict[str, List[str]] = {} |
| for eid, sym in mapping.items(): |
| sym = sym.upper() |
| if sym not in self._symbol_to_ensembl: |
| self._symbol_to_ensembl[sym] = [] |
| self._symbol_to_ensembl[sym].append(eid) |
| print(f" Loaded gene mapping: {len(mapping)} ENSEMBL IDs → {len(self._symbol_to_ensembl)} symbols") |
| except Exception as e: |
| print(f" Warning: Failed to load gene mapping ({e})") |
| self._symbol_to_ensembl = {} |
|
|
| def _load_smiles_cache(self, csv_path: str) -> None: |
| """Load SMILES from a ChEMBL CSV into _smiles_cache. |
| |
| CSV format: chembl_id,smiles |
| Keys in _smiles_cache: both chembl_id and (later) drug_name. |
| """ |
| if not csv_path or not os.path.exists(csv_path): |
| return |
|
|
| import csv as csv_mod |
| try: |
| with open(csv_path, newline="", encoding="utf-8") as f: |
| reader = csv_mod.DictReader(f) |
| for row in reader: |
| cid = row.get("chembl_id", "").strip() |
| smi = row.get("smiles", "").strip() |
| if cid and smi: |
| self._smiles_cache[cid] = smi |
| n_loaded = sum(1 for k in self._smiles_cache if k.startswith("CHEMBL")) |
| print(f" Loaded {n_loaded} SMILES from {os.path.basename(csv_path)}") |
| except Exception as e: |
| print(f" Warning: Failed to load SMILES CSV ({e})") |
|
|
| def _build_name_chembl_map(self) -> Dict[str, str]: |
| """Build drug_name → ChEMBL ID mapping from loaded conditions.""" |
| mapping: Dict[str, str] = {} |
| for cond in getattr(self, '_conditions', []): |
| name = cond.get('drug_name', '') |
| chembl = cond.get('chembl_id', '') |
| if name and chembl and not pd.isna(chembl): |
| mapping[name] = str(chembl).strip() |
| return mapping |
|
|
| def _align_genes(self, target_num_genes: int) -> None: |
| """Pad or truncate gene dimension to match target_num_genes. |
| |
| When SciPlex3 has a different number of genes than the CRISPRi |
| dataset (e.g., due to different HVG selection), this method pads |
| the expression matrix with zeros to match the target dimension. |
| |
| Parameters |
| ---------- |
| target_num_genes : int |
| Target number of genes (from the model / other dataset). |
| """ |
| if self.num_genes == target_num_genes: |
| return |
|
|
| if self.num_genes > target_num_genes: |
| |
| print(f" Aligning genes: truncating {self.num_genes} → {target_num_genes}") |
| self._X = self._X[:, :target_num_genes] |
| self.gene_names = self.gene_names[:target_num_genes] |
| self.num_genes = target_num_genes |
| self._gene_to_idx = {g: i for i, g in enumerate(self.gene_names)} |
| else: |
| |
| pad_width = target_num_genes - self.num_genes |
| print(f" Aligning genes: padding {self.num_genes} → {target_num_genes} (+{pad_width} zeros)") |
| self._X = np.pad(self._X, ((0, 0), (0, pad_width)), mode='constant') |
| self.gene_names = np.concatenate([ |
| self.gene_names, |
| np.array([f"PAD_{i}" for i in range(pad_width)]), |
| ]) |
| self.num_genes = target_num_genes |
| self._gene_to_idx = {g: i for i, g in enumerate(self.gene_names)} |
|
|
| |
| if hasattr(self, '_conditions') and self._conditions: |
| for cond in self._conditions: |
| old_vec = cond['pert_vec'] |
| if len(old_vec) < target_num_genes: |
| new_vec = np.zeros(target_num_genes, dtype=np.float32) |
| new_vec[:len(old_vec)] = old_vec |
| cond['pert_vec'] = new_vec |
| elif len(old_vec) > target_num_genes: |
| cond['pert_vec'] = old_vec[:target_num_genes] |
|
|
| |
| def _build_drug_target_map( |
| self, |
| drug_obs, |
| external_mapping: Optional[Dict[str, List[str]]], |
| gene_names: List[str], |
| ) -> Dict[str, np.ndarray]: |
| """Build per-drug target gene vectors in HVG space.""" |
| drug_target_map: Dict[str, np.ndarray] = {} |
|
|
| |
| for drug_name in drug_obs['perturbation'].unique(): |
| target_cat = drug_obs[drug_obs['perturbation'] == drug_name]['target'].iloc[0] |
| gene_targets = self._target_category_to_genes( |
| target_cat, gene_names, |
| self._gene_to_idx, getattr(self, '_symbol_to_ensembl', None) |
| ) |
| drug_target_map[drug_name] = gene_targets |
|
|
| |
| if external_mapping: |
| for drug_name, gene_list in external_mapping.items(): |
| targets = np.zeros(len(gene_names), dtype=np.float32) |
| for g in gene_list: |
| if g in self._gene_to_idx: |
| targets[self._gene_to_idx[g]] = 1.0 |
| drug_target_map[drug_name] = targets |
|
|
| return drug_target_map |
|
|
| def _build_perturbation_vector( |
| self, drug_name: str, external_mapping: Optional[Dict] |
| ) -> np.ndarray: |
| """Build multi-hot perturbation vector for a drug.""" |
| pert_vec = np.zeros(self.num_genes, dtype=np.float32) |
|
|
| |
| if drug_name in self._drug_target_map: |
| pert_vec = self._drug_target_map[drug_name].copy() |
|
|
| |
| if external_mapping and drug_name in external_mapping: |
| for g in external_mapping[drug_name]: |
| if g in self._gene_to_idx: |
| pert_vec[self._gene_to_idx[g]] = 1.0 |
|
|
| return pert_vec |
|
|
| |
| |
| |
| @staticmethod |
| def _normalize_counts(X: np.ndarray, target_sum: float = 1e4) -> np.ndarray: |
| """Library-size normalization + log1p.""" |
| from ..data.gene_selection import normalize_counts |
| return normalize_counts(X, target_sum=target_sum, log1p=True) |
|
|
| @staticmethod |
| def _select_hvg(X: np.ndarray, n_genes: int = 2000) -> np.ndarray: |
| """Select highly variable genes.""" |
| from ..data.gene_selection import select_hvg_by_variance |
| return select_hvg_by_variance(X, n_genes=n_genes) |
|
|
| @staticmethod |
| def _target_category_to_genes( |
| target_category: str, |
| gene_names: List[str], |
| gene_to_idx: Optional[Dict[str, int]] = None, |
| symbol_to_ensembl: Optional[Dict[str, List[str]]] = None, |
| ) -> np.ndarray: |
| """Map SciPlex3 protein target category to gene targets. |
| |
| This is a simplified mapping. For production use, replace with |
| a curated mapping from ChEMBL/DGIdb. |
| |
| Parameters |
| ---------- |
| target_category : str |
| SciPlex3 target annotation, e.g. "Aurora Kinase", "HDAC", "CDK" |
| gene_names : list of str |
| All gene names in the HVG space. |
| |
| Returns |
| ------- |
| gene_targets : [G] binary vector |
| """ |
| |
| |
| TARGET_GENE_MAP = { |
| "Aurora Kinase": ["AURKA", "AURKB", "AURKC"], |
| "EGFR": ["EGFR"], |
| "HDAC": ["HDAC1", "HDAC2", "HDAC3", "HDAC4", "HDAC5", "HDAC6", "HDAC7", "HDAC8", "HDAC9", "HDAC10", "HDAC11"], |
| "DNA alkylator": [], |
| "Bcl-2": ["BCL2", "BCL2L1"], |
| "PKC": ["PRKCA", "PRKCB", "PRKCG", "PRKCD", "PRKCE", "PRKCH", "PRKCI", "PRKCL", "PRKCM", "PRKCN", "PRKDC", "PRKD1", "PRKD2", "PRKD3"], |
| "CDK": ["CDK1", "CDK2", "CDK4", "CDK6", "CDK7", "CDK8", "CDK9", "CDK12", "CDK13"], |
| "Histone Methyltransferase": ["EZH2", "DOT1L", "PRMT1", "PRMT5", "SETD2", "SETDB1", "SUV39H1", "SUV39H2"], |
| "DNA/RNA Synthesis": ["TOP1", "TOP2A", "TOP2B", "RRM1", "RRM2", "TYMS", "DHFR"], |
| "IGF-1R": ["IGF1R"], |
| "PARP": ["PARP1", "PARP2", "PARP3", "PARP4", "PARP6", "PARP8", "PARP10", "PARP11", "PARP12", "PARP14", "PARP15"], |
| "Sirtuin": ["SIRT1", "SIRT2", "SIRT3", "SIRT4", "SIRT5", "SIRT6", "SIRT7"], |
| "TNF-alpha": ["TNF", "TNFRSF1A", "TNFRSF2"], |
| "JAK": ["JAK1", "JAK2", "JAK3", "TYK2"], |
| "Topoisomerase": ["TOP1", "TOP2A", "TOP2B"], |
| "CCR": ["CCR1", "CCR2", "CCR3", "CCR4", "CCR5", "CCR6", "CCR7", "CCR8", "CCR9", "CCR10"], |
| "HSP (e.g. HSP90)": ["HSP90AA1", "HSP90AB1", "HSPA1A", "HSPA1B", "HSPA5", "HSPA8", "HSPB1", "HSPD1", "HSPE1"], |
| "HIF": ["HIF1A", "EPAS1", "HIF3A"], |
| "Microtubule Associated": ["TUBB", "TUBA1A", "TUBA1B", "TUBA1C", "TUBB2A", "TUBB2B", "TUBB3", "TUBB4B"], |
| "VEGF": ["VEGFA", "VEGFB", "VEGFC", "VEGFD"], |
| "NF-kB": ["NFKB1", "NFKB2", "RELA", "RELB", "REL"], |
| "PI3K": ["PIK3CA", "PIK3CB", "PIK3CD", "PIK3CG", "PIK3R1", "PIK3R2", "PIK3R3"], |
| "AKT": ["AKT1", "AKT2", "AKT3"], |
| "mTOR": ["MTOR"], |
| "RAR": ["RARA", "RARB", "RARG"], |
| "RXR": ["RXRA", "RXRB", "RXRG"], |
| "BET": ["BRD2", "BRD3", "BRD4", "BRDT"], |
| "IAP": ["BIRC2", "BIRC3", "BIRC5", "BIRC6", "BIRC7", "XIAP"], |
| "Telomerase": ["TERT", "TERC"], |
| "Mdm2": ["MDM2", "MDM4"], |
| "TRAIL": ["TNFSF10", "TRAILR1", "TRAILR2", "TRAILR3", "TRAILR4"], |
| "TGF-beta/Smad": ["TGFB1", "TGFB2", "TGFB3", "SMAD1", "SMAD2", "SMAD3", "SMAD4", "SMAD5", "SMAD6", "SMAD7"], |
| "Wnt": ["CTNNB1", "APC", "AXIN1", "AXIN2", "GSK3B", "LRP5", "LRP6", "FZD1", "FZD2", "FZD3", "FZD4", "FZD5", "FZD6", "FZD7", "FZD8", "FZD9", "FZD10"], |
| "Notch": ["NOTCH1", "NOTCH2", "NOTCH3", "NOTCH4", "JAG1", "JAG2", "DLL1", "DLL3", "DLL4"], |
| "Hedgehog": ["SHH", "IHH", "DHH", "PTCH1", "PTCH2", "SMO", "GLI1", "GLI2", "GLI3"], |
| "p53": ["TP53"], |
| "Src": ["SRC", "FYN", "YES1", "FGR", "LCK", "HCK", "LYN", "BLK", "HCK", "MATK"], |
| "BCL-2": ["BCL2", "BCL2L1", "MCL1"], |
| "JAK/STAT": ["JAK1", "JAK2", "JAK3", "TYK2", "STAT1", "STAT2", "STAT3", "STAT4", "STAT5A", "STAT5B", "STAT6"], |
| "PKA": ["PRKACA", "PRKACB", "PRKACG", "PRKAR1A", "PRKAR1B", "PRKAR2A", "PRKAR2B"], |
| "PIP3": ["PIK3CA", "PIK3CB", "PIK3CD", "PIK3CG", "PTEN"], |
| "GSK-3": ["GSK3A", "GSK3B"], |
| "RAF": ["ARAF", "BRAF", "RAF1"], |
| "MEK": ["MAP2K1", "MAP2K2"], |
| "ERK": ["MAPK1", "MAPK3"], |
| "p38": ["MAPK11", "MAPK12", "MAPK13", "MAPK14"], |
| "JNK": ["MAPK8", "MAPK9", "MAPK10"], |
| "Caspase": ["CASP1", "CASP2", "CASP3", "CASP4", "CASP5", "CASP6", "CASP7", "CASP8", "CASP9", "CASP10"], |
| "Proteasome": ["PSMA1", "PSMA2", "PSMA3", "PSMA4", "PSMA5", "PSMA6", "PSMA7", "PSMB1", "PSMB2", "PSMB3", "PSMB4", "PSMB5", "PSMB6", "PSMB7", "PSMC1", "PSMC2", "PSMC3", "PSMC4", "PSMC5", "PSMC6", "PSMD1", "PSMD2", "PSMD3", "PSMD4", "PSMD5", "PSMD6", "PSMD7", "PSMD8", "PSMD9", "PSMD10", "PSMD11", "PSMD12", "PSMD13", "PSMD14"], |
| "Calcineurin": ["PPP3CA", "PPP3CB", "PPP3CC", "PPP3R1", "PPP3R2"], |
| "Carbonic Anhydrase": ["CA1", "CA2", "CA3", "CA4", "CA5A", "CA6", "CA7", "CA8", "CA9", "CA10", "CA11", "CA12", "CA13", "CA14"], |
| "Glucocorticoid Receptor": ["NR3C1"], |
| "Estrogen Receptor": ["ESR1", "ESR2"], |
| "Androgen Receptor": ["AR"], |
| "Progesterone Receptor": ["PGR"], |
| "Vitamin D Receptor": ["VDR"], |
| "Retinoic acid receptor": ["RARA", "RARB", "RARG"], |
| "FXR": ["NR1H4"], |
| "LXR": ["NR1H2", "NR1H3"], |
| "PPAR": ["PPARA", "PPARD", "PPARG"], |
| "Serotonin Receptor": ["HTR1A", "HTR1B", "HTR1D", "HTR1E", "HTR1F", "HTR2A", "HTR2B", "HTR2C", "HTR3A", "HTR3B", "HTR3C", "HTR3D", "HTR3E", "HTR4", "HTR5A", "HTR6", "HTR7"], |
| "Dopamine Receptor": ["DRD1", "DRD2", "DRD3", "DRD4", "DRD5"], |
| "Histamine Receptor": ["HRH1", "HRH2", "HRH3", "HRH4"], |
| "Adrenergic Receptor": ["ADRA1A", "ADRA1B", "ADRA1D", "ADRA2A", "ADRA2B", "ADRA2C", "ADRB1", "ADRB2", "ADRB3"], |
| "Sigma Receptor": ["SIGMAR1", "SIGMAR2"], |
| "Cannabinoid Receptor": ["CNR1", "CNR2"], |
| "Adenosine Receptor": ["ADORA1", "ADORA2A", "ADORA2B", "ADORA3"], |
| "Imidazoline Receptor": ["ADRA1A"], |
| "GABA-A Receptor": ["GABRA1", "GABRA2", "GABRA3", "GABRA4", "GABRA5", "GABRA6", "GABRB1", "GABRB2", "GABRB3", "GABRG1", "GABRG2", "GABRG3", "GABRD", "GABRE", "GABRP", "GABRQ", "GABRR1", "GABRR2", "GABRR3"], |
| "Glutamate Receptor": ["GRIA1", "GRIA2", "GRIA3", "GRIA4", "GRID1", "GRID2", "GRIN1", "GRIN2A", "GRIN2B", "GRIN2C", "GRIN2D", "GRIN3A", "GRIN3B", "GRIK1", "GRIK2", "GRIK3", "GRIK4", "GRIK5", "GRM1", "GRM2", "GRM3", "GRM4", "GRM5", "GRM6", "GRM7", "GRM8"], |
| "Muscarinic Acetylcholine Receptor": ["CHRM1", "CHRM2", "CHRM3", "CHRM4", "CHRM5"], |
| "Nicotinic Acetylcholine Receptor": ["CHRNA1", "CHRNA2", "CHRNA3", "CHRNA4", "CHRNA5", "CHRNA6", "CHRNA7", "CHRNA9", "CHRNA10", "CHRNB1", "CHRNB2", "CHRNB3", "CHRNB4", "CHRND", "CHRNE", "CHRNG"], |
| "Opioid Receptor": ["OPRD1", "OPRK1", "OPRL1", "OPRM1"], |
| "Angiotensin Receptor": ["AGTR1", "AGTR2"], |
| "Chemokine Receptor": ["CCR1", "CCR2", "CCR3", "CCR4", "CCR5", "CCR6", "CCR7", "CCR8", "CCR9", "CCR10", "CXCR1", "CXCR2", "CXCR3", "CXCR4", "CXCR5", "CXCR6", "CXCR7", "XCR1", "CX3CR1"], |
| "Cytokine Receptor": ["IL1R1", "IL2RA", "IL2RB", "IL2RG", "IL3RA", "IL4R", "IL5RA", "IL6R", "IL7R", "CSF1R", "CSF2R", "CSF3R", "EPOR", "FLT3", "KIT", "MPL"], |
| "Tachykinin Receptor": ["TACR1", "TACR2", "TACR3"], |
| "Platelet-derived growth factor receptor": ["PDGFRA", "PDGFRB"], |
| "Fibroblast growth factor receptor": ["FGFR1", "FGFR2", "FGFR3", "FGFR4"], |
| "Vascular endothelial growth factor receptor": ["FLT1", "KDR", "FLT4"], |
| "Folate Receptor": ["FOLR1", "FOLR2", "FOLR3"], |
| "NMDA Receptor": ["GRIN1", "GRIN2A", "GRIN2B", "GRIN2C", "GRIN2D"], |
| "AMPA Receptor": ["GRIA1", "GRIA2", "GRIA3", "GRIA4"], |
| "Kainate Receptor": ["GRIK1", "GRIK2", "GRIK3", "GRIK4", "GRIK5"], |
| "Neurotrophin Receptor": ["NTRK1", "NTRK2", "NTRK3"], |
| "Trk Receptor": ["NTRK1", "NTRK2", "NTRK3"], |
| "Neuropeptide Receptor": [], |
| "Protein kinase": [], |
| "DNA polymerase": [], |
| "RNA polymerase": [], |
| "Ribosome": [], |
| "Tubulin": ["TUBB", "TUBA1A", "TUBA1B", "TUBA1C", "TUBB2A", "TUBB2B", "TUBB3", "TUBB4B"], |
| "Farnesyltransferase": ["FNTA", "FNTB"], |
| "Geranylgeranyltransferase": ["PGGT1B", "RABGGTA", "RABGGTB"], |
| "Dihydrofolate reductase": ["DHFR"], |
| "Bacterial cell wall": [], |
| "Fungal cell wall": [], |
| "Bacterial ribosome": [], |
| "Fungal ribosome": [], |
| "Mitochondrial ribosome": ["MRPL1", "MRPL2", "MRPL3", "MRPL4", "MRPL5", "MRPL6", "MRPL7", "MRPL8", "MRPL9", "MRPL10", "MRPL11", "MRPL12", "MRPL13", "MRPL14", "MRPL15", "MRPL16", "MRPL17", "MRPL18", "MRPL19", "MRPL20", "MRPL21", "MRPL22", "MRPL23", "MRPL24", "MRPL27", "MRPL28", "MRPL30", "MRPL32", "MRPL33", "MRPL34", "MRPL35", "MRPL36", "MRPL37", "MRPL38", "MRPL39", "MRPL40", "MRPL41", "MRPL42", "MRPL43", "MRPL44", "MRPL45", "MRPL48", "MRPL49", "MRPL50", "MRPS2", "MRPS5", "MRPS6", "MRPS7", "MRPS9", "MRPS10", "MRPS11", "MRPS12", "MRPS14", "MRPS15", "MRPS16", "MRPS17", "MRPS18A", "MRPS18B", "MRPS18C", "MRPS21", "MRPS22", "MRPS23", "MRPS24", "MRPS25", "MRPS26", "MRPS27", "MRPS28", "MRPS30", "MRPS31", "MRPS33", "MRPS34", "MRPS35"], |
| } |
|
|
| pert_vec = np.zeros(len(gene_names), dtype=np.float32) |
| target_genes = TARGET_GENE_MAP.get(target_category, []) |
|
|
| |
| if gene_to_idx is None: |
| gene_to_idx = {g: i for i, g in enumerate(gene_names)} |
|
|
| |
| gene_name_set = set(gene_names) |
| if symbol_to_ensembl and any(str(g).startswith('ENSG') for g in gene_names[:10]): |
| converted_targets = [] |
| for g in target_genes: |
| g_upper = g.upper() |
| if g_upper in symbol_to_ensembl: |
| for eid in symbol_to_ensembl[g_upper]: |
| if eid in gene_name_set: |
| converted_targets.append(eid) |
| target_genes = converted_targets |
|
|
| for g in target_genes: |
| if g in gene_to_idx: |
| pert_vec[gene_to_idx[g]] = 1.0 |
|
|
| return pert_vec |
|
|
| |
| |
| |
| @property |
| def condition_names(self) -> List[str]: |
| return [c['pert_name'] for c in self._conditions] |
|
|
| @property |
| def unique_drugs(self) -> List[str]: |
| return sorted(set(c['drug_name'] for c in self._conditions)) |
|
|
| @property |
| def cell_line_list(self) -> List[str]: |
| return sorted(set(c['cell_line'] for c in self._conditions)) |
|
|
|
|
| |
| |
| |
| def sciplex_collate_fn(batch: List[Dict], num_genes: Optional[int] = None) -> Dict[str, torch.Tensor]: |
| """Collate function for SciPlex3Dataset batches. |
| |
| Handles variable-size cell populations by padding to the max size |
| in the batch. Optionally pads gene dimension to match `num_genes` |
| for cross-dataset training (e.g., CRISPRi + SciPlex3). |
| |
| Parameters |
| ---------- |
| batch : list of dicts |
| num_genes : int, optional |
| Target gene dimension. If provided and larger than batch genes, |
| pads with zeros. |
| """ |
| B = len(batch) |
| max_ns = max(b['source_cells'].size(0) for b in batch) |
| max_nt = max(b['target_cells'].size(0) for b in batch) |
| G = batch[0]['source_cells'].size(1) |
|
|
| device = batch[0]['source_cells'].device |
|
|
| |
| target_G = num_genes if num_genes is not None else G |
| gene_pad = max(0, target_G - G) |
|
|
| source_cells = torch.zeros(B, max_ns, target_G, device=device) |
| target_cells = torch.zeros(B, max_nt, target_G, device=device) |
| source_mask = torch.zeros(B, max_ns, device=device) |
| target_mask = torch.zeros(B, max_nt, device=device) |
| perturbation = torch.zeros(B, target_G, device=device) |
| drug_smiles = [b['drug_smiles'] for b in batch] |
| dose = torch.stack([b['dose'] for b in batch]) |
|
|
| for i, b in enumerate(batch): |
| ns = b['source_cells'].size(0) |
| nt = b['target_cells'].size(0) |
| |
| source_cells[i, :ns, :G] = b['source_cells'] |
| target_cells[i, :nt, :G] = b['target_cells'] |
| |
| pert = b['perturbation'] |
| perturbation[i, :pert.size(0)] = pert |
| source_mask[i, :ns] = 1.0 |
| target_mask[i, :nt] = 1.0 |
|
|
| return { |
| "source_cells": source_cells, |
| "target_cells": target_cells, |
| "source_mask": source_mask, |
| "target_mask": target_mask, |
| "perturbation": perturbation, |
| "drug_smiles": drug_smiles, |
| "dose": dose, |
| "metadata": [b['metadata'] for b in batch], |
| } |
|
|