import pickle
from pathlib import Path
import importlib.resources as resources
import numpy as np
import pandas as pd
from PIL import Image
from mordred import (Calculator, AdjacencyMatrix, Autocorrelation, EState, DistanceMatrix,
TopologicalIndex, BCUT, MoeType, RingCount, BaryszMatrix, ExtendedTopochemicalAtom,
TopologicalCharge)
from rdkit import Chem
from rdkit.Chem import Draw, MACCSkeys, rdMolDescriptors
[docs]
class amberNPS:
"""
Predicts drug class and lethal blood concentration (LBC) values from SMILES strings.
The AmberNPS API provides drug classification and prediction of LBC values
using pre-trained machine learning models stored as pickle files.
Parameters
----------
mlp : str or Path
Path to the Multitask Regressor model file (`.pkl`).
scaler : str or Path
Path to the Scaler used to normalize Mordred descriptors.
rf : str or Path
Path to the Random Forest model used for drug class prediction.
le : str or Path
Path to the LabelEncoder used to map numeric labels to class names.
Attributes
----------
smiles : str
The input SMILES string provided to `predict()`.
mol : rdkit.Chem.Mol
RDKit molecule object created from the SMILES.
mw : float
Exact molecular weight of the molecule.
drug_class : str
Predicted drug class label.
LBC50 : float
Median predicted lethal blood concentration (ng/mL or µg/mL).
LOLBC : float
Lower bound of lethal blood concentration range.
HOLBC : float
Upper bound of lethal blood concentration range.
structure : PIL.Image.Image
Rendered image of the molecule structure.
Methods
-------
predict(smiles)
Predicts drug class and concentration values for the given SMILES.
to_dict()
Returns the results as a dictionary for easy serialization.
structure
Property that returns an image of the molecule.
convert_pLBC_to_LBC(pLBC, mw)
Converts predicted -log(LBC) values to actual concentrations.
"""
def __init__(self,
mlp: str = "multitask_regressor.pkl",
scaler: str = "scaler.pkl",
rf: str = "random_forest_model.pkl",
le: str = "label_encoder.pkl"
):
self.mlp = self._load_pickle(mlp)
self.scaler = self._load_pickle(scaler)
self.rf = self._load_pickle(rf)
self.le = self._load_pickle(le)
self.calc = self._register_descriptors(Calculator)
self.drug_class = None
self.LOLBC = None
self.LBC50 = None
self.HOLBC = None
self.pLOLBC = None
self.pLBC50 = None
self.pHOLBC = None
self.mol = None
self.smiles = None
self.lbc_preds = None
self.mw = None
[docs]
@classmethod
def convert_pLBC_to_LBC(cls, pLBC: float, mw: float) -> float:
"""Performs antilog transformation of the predicted LBC values"""
LBCmol = 10 ** -pLBC
LBC = LBCmol * mw
return LBC
@staticmethod
def _load_pickle(filename: str) -> None:
"""
Loads a pickle file safely, accepting string or Path-like objects.
Raises:
TypeError: if file_path is not str or Path
FileNotFoundError: if the file does not exist
pickle.UnpicklingError: if pickle fails to load
"""
try:
with resources.files('amberNPS').joinpath("models", filename).open('rb') as f:
return pickle.load(f)
except FileNotFoundError:
raise FileNotFoundError(f"Could not find {filename} inside package models directory.")
except pickle.UnpicklingError as e:
raise ValueError(f"Failed to unpickle {filename}: {e}")
@staticmethod
def _register_descriptors(calculator) -> Calculator:
"""Register specific Mordred descriptors used for predictions."""
c = calculator()
c.register(AdjacencyMatrix.AdjacencyMatrix('VR1'))
c.register(Autocorrelation.ATSC(0, 'p'))
c.register(Autocorrelation.ATSC(2, 'i'))
c.register(DistanceMatrix.DistanceMatrix('SpMax'))
c.register(EState.AtomTypeEState('count', 'dCH2'))
c.register(EState.AtomTypeEState('sum', 'aaaC'))
c.register(MoeType.EState_VSA(2))
c.register(RingCount.RingCount(11, False, True, True, None))
c.register(Autocorrelation.ATS(5, 's'))
c.register(Autocorrelation.ATSC(0, 'Z'))
c.register(BCUT.BCUT('d', -1))
c.register(BaryszMatrix.BaryszMatrix('are', 'SpDiam'))
c.register(EState.AtomTypeEState('sum', 'dCH2'))
c.register(ExtendedTopochemicalAtom.EtaVEMCount('ns_d', True))
c.register(RingCount.RingCount(11, False, True, False, True))
c.register(TopologicalCharge.TopologicalCharge('raw', 9))
c.register(AdjacencyMatrix.AdjacencyMatrix('VR2'))
c.register(Autocorrelation.ATSC(5, 's'))
c.register(Autocorrelation.AATSC(1, 's'))
c.register(Autocorrelation.GATS(7, 's'))
c.register(BaryszMatrix.BaryszMatrix('are', 'SpMAD'))
c.register(EState.AtomTypeEState('sum', 'dO'))
c.register(MoeType.PEOE_VSA(13))
c.register(MoeType.VSA_EState(8))
c.register(RingCount.RingCount(5, True, False, False, None))
c.register(TopologicalIndex.Diameter())
c.register(TopologicalIndex.PetitjeanIndex())
return c
@property
def structure(self) -> Image:
"""Generates image of structure in the console"""
img = Draw.MolToImage(self.mol)
return img.show()
[docs]
def predict(self, smiles: str) -> dict:
"""
Predicts the drug class and lethal blood concentrations (LBC, in
ng/mL) for the provided smiles and sets them as instance properties.
"""
if not isinstance(smiles, str):
raise TypeError(f"Expected str, got {type(smiles).__name__}")
# Convert smiles to mol object
self.smiles = smiles
self.mol = Chem.MolFromSmiles(self.smiles)
if not self.mol:
raise ValueError(f'Could not parse smiles to mol object: {self.smiles}')
# calculate exact molecular weight
self.mw = rdMolDescriptors.CalcExactMolWt(self.mol)
# Predict drug class and LBCs
try:
self.drug_class = self._predict_drug_class()
self.lbc_preds = self._predict_lbc()
except Exception as e:
raise RuntimeError(f"Prediction failed for {smiles}: {e}")
# Unpack predictions
self.pLOLBC, self.pLBC50, self.pHOLBC = self.lbc_preds
# Perform antilog transformation
self.LOLBC = self.convert_pLBC_to_LBC(self.lbc_preds[0], self.mw)
self.LBC50 = self.convert_pLBC_to_LBC(self.lbc_preds[1], self.mw)
self.HOLBC = self.convert_pLBC_to_LBC(self.lbc_preds[2], self.mw)
return self.to_dict()
[docs]
def to_dict(self) -> dict[str, float | str]:
"""
Returns a dictionary containing predicted drug class and LBC values.
"""
return {
'Drug Class': self.drug_class,
'LOLBC': self.LOLBC,
'LBC50': self.LBC50,
'HOLBC': self.HOLBC,
}
# -------------------
# Private Members
# -------------------
def _compute_maccs(self) -> pd.DataFrame:
"""
Generates the MACCS keys for the compound used for predicting
the compound's drug class.
"""
maccs = MACCSkeys.GenMACCSKeys(self.mol)
maccs_df = pd.DataFrame([list(maccs)[1:]], columns=[f"MACCS_{i}" for i in range(1, 167)])
return maccs_df
def _predict_lbc(self) -> list[float]:
"""
Predicts the low, median and high LBC and return a list.
i.e. [pLOLBC, pLBC50, pHOLBC]
"""
features = self._compute_features()
features = self.scaler.transform(features)
pred = self.mlp.predict(features).flatten().tolist()
return pred
def _predict_drug_class(self) -> str:
"""Predicts drug class using trained random forest classifier"""
maccs_keys = self._compute_maccs()
drug_class = self.rf.predict(maccs_keys)
drug_class = self.le.inverse_transform(drug_class)[0]
return drug_class
def _compute_features(self) -> np.ndarray:
"""Compute feature array for predictions using Mordred calculator"""
features = np.array(self.calc(self.mol)).reshape(1, -1)
return features