File size: 3,325 Bytes
bc2a98e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Aquatic toxicity prediction using trained RF + Morgan fingerprint models."""
from pathlib import Path

import joblib
import numpy as np
import yaml
from rdkit import Chem
from rdkit.Chem import AllChem, Descriptors


def _load_config() -> dict:
    config_path = Path(__file__).resolve().parent.parent / "configs" / "paths.yaml"
    with open(config_path, "r", encoding="utf-8") as f:
        return yaml.safe_load(f)


def _smiles_to_features(smiles: str, radius: int = 2, n_bits: int = 2048) -> np.ndarray:
    mol = Chem.MolFromSmiles(smiles)
    if mol is None:
        raise ValueError(f"Invalid SMILES: {smiles}")
    fp = AllChem.GetMorganFingerprintAsBitVect(mol, radius=radius, nBits=n_bits)
    fp_arr = np.array(fp, dtype=np.float32)
    desc = [
        Descriptors.MolLogP(mol), Descriptors.MolWt(mol), Descriptors.TPSA(mol),
        Descriptors.NumHDonors(mol), Descriptors.NumHAcceptors(mol),
        Descriptors.NumRotatableBonds(mol), Descriptors.HeavyAtomCount(mol),
    ]
    return np.concatenate([fp_arr, np.array(desc, dtype=np.float32)])


_ENDPOINT_MAP = {
    "LC50": "rf_lc50.pkl",
    "LC50DM": "rf_lc50dm.pkl",
    "IGC50": "rf_igc50.pkl",
    "IBC50": "rf_ibc50.pkl",
}

_UNIT = "-log10(mol/L)"

_CONFIDENCE = {
    "LC50": "high",
    "LC50DM": "medium",
    "IGC50": "high",
    "IBC50": "low",
}


def predict_toxicity(linker_smiles: str | None) -> dict:
    """Predict aquatic toxicity for 4 endpoints from a linker SMILES.

    Args:
        linker_smiles: SMILES string or None (Level 3 linker extraction).

    Returns:
        dict with keys: LC50_Pimephales, LC50_Daphnia, IGC50_Tetrahymena,
        IBC50_Vibrio (float|None), plus _unit and _confidence for each,
        and applicability_domain_flag (bool).
        Values are -log10(mol/L); higher = more toxic.
    """
    result = {
        "LC50_Pimephales": None, "LC50_Pimephales_unit": _UNIT, "LC50_Pimephales_confidence": None,
        "LC50_Daphnia": None, "LC50_Daphnia_unit": _UNIT, "LC50_Daphnia_confidence": None,
        "IGC50_Tetrahymena": None, "IGC50_Tetrahymena_unit": _UNIT, "IGC50_Tetrahymena_confidence": None,
        "IBC50_Vibrio": None, "IBC50_Vibrio_unit": _UNIT, "IBC50_Vibrio_confidence": None,
        "applicability_domain_flag": False,
    }

    if linker_smiles is None:
        return result

    cfg = _load_config()
    model_dir = Path(cfg["toxicity_models"]["model_dir"])
    fp_cfg = cfg["toxicity_models"]["fingerprint"]

    try:
        features = _smiles_to_features(
            linker_smiles, radius=fp_cfg["radius"], n_bits=fp_cfg["n_bits"]
        )
    except ValueError:
        return result

    X = features.reshape(1, -1)
    key_map = {
        "LC50": "LC50_Pimephales",
        "LC50DM": "LC50_Daphnia",
        "IGC50": "IGC50_Tetrahymena",
        "IBC50": "IBC50_Vibrio",
    }

    all_ok = True
    for endpoint, filename in _ENDPOINT_MAP.items():
        model_path = model_dir / filename
        if not model_path.exists():
            all_ok = False
            continue
        model = joblib.load(str(model_path))
        pred = float(model.predict(X)[0])
        out_key = key_map[endpoint]
        result[out_key] = round(pred, 4)
        result[f"{out_key}_confidence"] = _CONFIDENCE[endpoint]

    result["applicability_domain_flag"] = all_ok
    return result