PharmAI-models / models /cyp /src /features /rdkit_features.py
Hattie's picture
Add model files
91f863a
Raw
History Blame Contribute Delete
4.52 kB
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, Tuple, Optional, List
import numpy as np
import pandas as pd
from rdkit import Chem, DataStructs, RDLogger
from rdkit.Chem import AllChem, Descriptors, Lipinski, Crippen
@dataclass(frozen=True)
class FeatConfig:
fp_radius: int = 2
fp_nbits: int = 2048
def _morgan_fp(mol, radius: int, nbits: int) -> np.ndarray:
fp = AllChem.GetMorganFingerprintAsBitVect(mol, radius, nBits=nbits)
arr = np.zeros((nbits,), dtype=np.int8)
DataStructs.ConvertToNumpyArray(fp, arr)
return arr
def _physchem_from_mol(mol) -> Dict[str, float]:
return {
"rd_mw": float(Descriptors.MolWt(mol)),
"rd_logp": float(Crippen.MolLogP(mol)),
"rd_tpsa": float(Descriptors.TPSA(mol)),
"rd_hbd": float(Lipinski.NumHDonors(mol)),
"rd_hba": float(Lipinski.NumHAcceptors(mol)),
"rd_rotb": float(Lipinski.NumRotatableBonds(mol)),
"rd_rings": float(Lipinski.RingCount(mol)),
"rd_heavy_atoms": float(Descriptors.HeavyAtomCount(mol)),
}
def _merge_override(row: pd.Series, rd_vals: Dict[str, float], override_physchem: bool) -> Dict[str, float]:
if not override_physchem:
return rd_vals
out = {}
for k, v in rd_vals.items():
if k in row.index and pd.notna(row[k]):
try:
out[k] = float(row[k])
except Exception:
out[k] = v
else:
out[k] = v
return out
def featurize_smiles(
df: pd.DataFrame,
smiles_col: str = "smiles",
config: FeatConfig = FeatConfig(),
add_physchem: bool = True,
drop_invalid: bool = True,
override_physchem: bool = True,
suppress_rdkit_warnings: bool = True,
) -> Tuple[pd.DataFrame, pd.Series]:
"""
Returns:
X (features dataframe), valid_mask (bool Series aligned to input df)
Key guarantee:
- Internal lists keep same length as df (one entry per row),
then we filter by valid_mask at the end (prevents shape mismatch).
"""
if suppress_rdkit_warnings:
RDLogger.DisableLog("rdApp.*")
smiles_list = df[smiles_col].astype(str).tolist()
n = len(smiles_list)
# Prepare keys once
dummy = _physchem_from_mol(Chem.MolFromSmiles("CC"))
phys_keys = list(dummy.keys())
fps_all: List[Optional[np.ndarray]] = [None] * n
phys_all: List[Optional[Dict[str, float]]] = [None] * n
valid: List[bool] = [False] * n
for i, s in enumerate(smiles_list):
mol = Chem.MolFromSmiles(s)
if mol is None:
valid[i] = False
continue
valid[i] = True
fps_all[i] = _morgan_fp(mol, config.fp_radius, config.fp_nbits)
if add_physchem:
rd_vals = _physchem_from_mol(mol)
phys_all[i] = _merge_override(df.iloc[i], rd_vals, override_physchem)
valid_mask = pd.Series(valid, index=df.index)
if drop_invalid:
keep_idx = df.index[valid_mask]
fps_kept = [fp for fp, ok in zip(fps_all, valid) if ok]
if len(fps_kept) == 0:
raise ValueError("No valid SMILES found after filtering.")
fp_mat = np.vstack(fps_kept)
X_fp = pd.DataFrame(
fp_mat,
columns=[f"fp_{i}" for i in range(config.fp_nbits)],
index=keep_idx,
)
if add_physchem:
phys_kept = [p for p, ok in zip(phys_all, valid) if ok]
X_phys = pd.DataFrame(phys_kept, index=keep_idx)
# stable schema
for k in phys_keys:
if k not in X_phys.columns:
X_phys[k] = np.nan
X_phys = X_phys[phys_keys]
X = pd.concat([X_phys, X_fp], axis=1)
else:
X = X_fp
return X, valid_mask
# drop_invalid = False (keep row count, fill invalid with zeros/NaN)
fp_mat = np.vstack([fp if fp is not None else np.zeros((config.fp_nbits,), dtype=np.int8) for fp in fps_all])
X_fp = pd.DataFrame(fp_mat, columns=[f"fp_{i}" for i in range(config.fp_nbits)], index=df.index)
if add_physchem:
phys_rows = []
for p in phys_all:
if p is None:
phys_rows.append({k: np.nan for k in phys_keys})
else:
phys_rows.append(p)
X_phys = pd.DataFrame(phys_rows, index=df.index)
X_phys = X_phys[phys_keys]
X = pd.concat([X_phys, X_fp], axis=1)
else:
X = X_fp
return X, valid_mask