Spaces:
Paused
Paused
Upload folder using huggingface_hub
Browse files- src/__init__.py +0 -0
- src/__pycache__/__init__.cpython-310.pyc +0 -0
- src/__pycache__/admet.cpython-310.pyc +0 -0
- src/__pycache__/analogs.cpython-310.pyc +0 -0
- src/__pycache__/batch_screen.cpython-310.pyc +0 -0
- src/__pycache__/chemical_space.cpython-310.pyc +0 -0
- src/__pycache__/conformers.cpython-310.pyc +0 -0
- src/__pycache__/data.cpython-310.pyc +0 -0
- src/__pycache__/ensemble.cpython-310.pyc +0 -0
- src/__pycache__/evaluate.cpython-310.pyc +0 -0
- src/__pycache__/explain.cpython-310.pyc +0 -0
- src/__pycache__/train.cpython-310.pyc +0 -0
- src/admet.py +179 -0
- src/analogs.py +132 -0
- src/batch_screen.py +134 -0
- src/chemical_space.py +148 -0
- src/conformers.py +121 -0
- src/data.py +376 -0
- src/ensemble.py +149 -0
- src/evaluate.py +342 -0
- src/explain.py +207 -0
- src/models/__init__.py +0 -0
- src/models/__pycache__/__init__.cpython-310.pyc +0 -0
- src/models/__pycache__/baseline.cpython-310.pyc +0 -0
- src/models/__pycache__/gnn.cpython-310.pyc +0 -0
- src/models/baseline.py +215 -0
- src/models/gnn.py +298 -0
- src/train.py +398 -0
src/__init__.py
ADDED
|
File without changes
|
src/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (139 Bytes). View file
|
|
|
src/__pycache__/admet.cpython-310.pyc
ADDED
|
Binary file (4.18 kB). View file
|
|
|
src/__pycache__/analogs.cpython-310.pyc
ADDED
|
Binary file (4.06 kB). View file
|
|
|
src/__pycache__/batch_screen.cpython-310.pyc
ADDED
|
Binary file (3.39 kB). View file
|
|
|
src/__pycache__/chemical_space.cpython-310.pyc
ADDED
|
Binary file (4.97 kB). View file
|
|
|
src/__pycache__/conformers.cpython-310.pyc
ADDED
|
Binary file (3.54 kB). View file
|
|
|
src/__pycache__/data.cpython-310.pyc
ADDED
|
Binary file (10.9 kB). View file
|
|
|
src/__pycache__/ensemble.cpython-310.pyc
ADDED
|
Binary file (3.82 kB). View file
|
|
|
src/__pycache__/evaluate.cpython-310.pyc
ADDED
|
Binary file (12.5 kB). View file
|
|
|
src/__pycache__/explain.cpython-310.pyc
ADDED
|
Binary file (6.47 kB). View file
|
|
|
src/__pycache__/train.cpython-310.pyc
ADDED
|
Binary file (11.1 kB). View file
|
|
|
src/admet.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ADMET & In Silico Drug-Likeness Profiling Engine.
|
| 3 |
+
Computes physicochemical descriptors, Lipinski Rule of 5, Veber, Ghose, Egan,
|
| 4 |
+
QED, PAINS filter alerts, Brenk toxicophores, and Bioavailability Radar coordinates.
|
| 5 |
+
"""
|
| 6 |
+
from typing import Dict, Any, List, Tuple, Optional
|
| 7 |
+
import numpy as np
|
| 8 |
+
from rdkit import Chem
|
| 9 |
+
from rdkit.Chem import Descriptors, Lipinski, Crippen, QED
|
| 10 |
+
from rdkit.Chem.FilterCatalog import FilterCatalog, FilterCatalogParams
|
| 11 |
+
|
| 12 |
+
# Initialize FilterCatalogs for PAINS and BRENK structural alerts
|
| 13 |
+
_pains_params = FilterCatalogParams()
|
| 14 |
+
_pains_params.AddCatalog(FilterCatalogParams.FilterCatalogs.PAINS)
|
| 15 |
+
PAINS_CATALOG = FilterCatalog(_pains_params)
|
| 16 |
+
|
| 17 |
+
_brenk_params = FilterCatalogParams()
|
| 18 |
+
_brenk_params.AddCatalog(FilterCatalogParams.FilterCatalogs.BRENK)
|
| 19 |
+
BRENK_CATALOG = FilterCatalog(_brenk_params)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def compute_admet_profile(mol_or_smiles: Any) -> Dict[str, Any]:
|
| 23 |
+
"""
|
| 24 |
+
Compute comprehensive in silico ADMET and drug-likeness profile.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
mol_or_smiles: RDKit Mol object or SMILES string.
|
| 28 |
+
|
| 29 |
+
Returns:
|
| 30 |
+
Dictionary containing descriptors, rule evaluations, alerts, and scores.
|
| 31 |
+
"""
|
| 32 |
+
if isinstance(mol_or_smiles, str):
|
| 33 |
+
mol = Chem.MolFromSmiles(mol_or_smiles)
|
| 34 |
+
else:
|
| 35 |
+
mol = mol_or_smiles
|
| 36 |
+
|
| 37 |
+
if mol is None:
|
| 38 |
+
raise ValueError("Invalid molecule provided for ADMET calculation.")
|
| 39 |
+
|
| 40 |
+
# 1. Fundamental Physicochemical Descriptors
|
| 41 |
+
mw = float(Descriptors.MolWt(mol))
|
| 42 |
+
logp = float(Crippen.MolLogP(mol))
|
| 43 |
+
tpsa = float(Descriptors.TPSA(mol))
|
| 44 |
+
hbd = int(Lipinski.NumHDonors(mol))
|
| 45 |
+
hba = int(Lipinski.NumHAcceptors(mol))
|
| 46 |
+
rot_bonds = int(Lipinski.NumRotatableBonds(mol))
|
| 47 |
+
heavy_atoms = int(mol.GetNumHeavyAtoms())
|
| 48 |
+
aromatic_rings = int(Lipinski.NumAromaticRings(mol))
|
| 49 |
+
fraction_csp3 = float(Descriptors.FractionCSP3(mol))
|
| 50 |
+
molar_refractivity = float(Crippen.MolMR(mol))
|
| 51 |
+
qed_score = float(QED.qed(mol))
|
| 52 |
+
|
| 53 |
+
# Synthetic Accessibility Approximation (heuristic based on complexity, rings, chiral centers)
|
| 54 |
+
num_chiral = len(Chem.FindMolChiralCenters(mol, includeUnassigned=True))
|
| 55 |
+
num_rings = Descriptors.RingCount(mol)
|
| 56 |
+
bertz = Descriptors.BertzCT(mol)
|
| 57 |
+
# Normalized complexity heuristic between 1.0 (very easy) and 10.0 (very hard)
|
| 58 |
+
sascore_est = min(10.0, max(1.0, 1.0 + (bertz / 250.0) + (num_chiral * 0.4) + (max(0, num_rings - 2) * 0.5)))
|
| 59 |
+
|
| 60 |
+
# 2. Rule of 5 (Lipinski)
|
| 61 |
+
lipinski_violations = []
|
| 62 |
+
if mw > 500:
|
| 63 |
+
lipinski_violations.append(f"MW > 500 ({mw:.1f} Da)")
|
| 64 |
+
if logp > 5.0:
|
| 65 |
+
lipinski_violations.append(f"LogP > 5.0 ({logp:.2f})")
|
| 66 |
+
if hbd > 5:
|
| 67 |
+
lipinski_violations.append(f"HBD > 5 ({hbd})")
|
| 68 |
+
if hba > 10:
|
| 69 |
+
lipinski_violations.append(f"HBA > 10 ({hba})")
|
| 70 |
+
|
| 71 |
+
lipinski_pass = len(lipinski_violations) <= 1
|
| 72 |
+
|
| 73 |
+
# 3. Veber Rule (Oral Bioavailability)
|
| 74 |
+
veber_violations = []
|
| 75 |
+
if rot_bonds > 10:
|
| 76 |
+
veber_violations.append(f"Rotatable Bonds > 10 ({rot_bonds})")
|
| 77 |
+
if tpsa > 140.0:
|
| 78 |
+
veber_violations.append(f"TPSA > 140 Ų ({tpsa:.1f})")
|
| 79 |
+
veber_pass = len(veber_violations) == 0
|
| 80 |
+
|
| 81 |
+
# 4. Ghose Filter
|
| 82 |
+
ghose_violations = []
|
| 83 |
+
if not (160 <= mw <= 480):
|
| 84 |
+
ghose_violations.append(f"MW not in [160, 480] ({mw:.1f})")
|
| 85 |
+
if not (-0.4 <= logp <= 5.6):
|
| 86 |
+
ghose_violations.append(f"LogP not in [-0.4, 5.6] ({logp:.2f})")
|
| 87 |
+
if not (40 <= molar_refractivity <= 130):
|
| 88 |
+
ghose_violations.append(f"MR not in [40, 130] ({molar_refractivity:.1f})")
|
| 89 |
+
if not (20 <= heavy_atoms <= 70):
|
| 90 |
+
ghose_violations.append(f"Heavy atoms not in [20, 70] ({heavy_atoms})")
|
| 91 |
+
ghose_pass = len(ghose_violations) == 0
|
| 92 |
+
|
| 93 |
+
# 5. Egan Filter (Absorption)
|
| 94 |
+
egan_violations = []
|
| 95 |
+
if logp > 5.88:
|
| 96 |
+
egan_violations.append(f"LogP > 5.88 ({logp:.2f})")
|
| 97 |
+
if tpsa > 131.6:
|
| 98 |
+
egan_violations.append(f"TPSA > 131.6 ({tpsa:.1f})")
|
| 99 |
+
egan_pass = len(egan_violations) == 0
|
| 100 |
+
|
| 101 |
+
# 6. PAINS Filter (Pan-Assay Interference Compounds)
|
| 102 |
+
pains_matches = []
|
| 103 |
+
for entry in PAINS_CATALOG.GetMatches(mol):
|
| 104 |
+
pains_matches.append(entry.GetDescription())
|
| 105 |
+
|
| 106 |
+
# 7. Brenk Filter (Toxicophores & Reactive Groups)
|
| 107 |
+
brenk_matches = []
|
| 108 |
+
for entry in BRENK_CATALOG.GetMatches(mol):
|
| 109 |
+
brenk_matches.append(entry.GetDescription())
|
| 110 |
+
|
| 111 |
+
# 8. Bioavailability Radar Coordinates (Normalized to [0, 1] relative to drug-like box)
|
| 112 |
+
# Axes: LIPO (LogP), SIZE (MW), POLAR (TPSA), INSOLU (LogS proxy/LogP), INSATU (FractionCsp3), FLEX (RotBonds)
|
| 113 |
+
# Optimal ranges:
|
| 114 |
+
# LIPO: -0.7 to 5.0
|
| 115 |
+
# SIZE: 150 to 500
|
| 116 |
+
# POLAR: 20 to 130
|
| 117 |
+
# INSOLU: 0 to 6
|
| 118 |
+
# INSATU: 0.25 to 1.0
|
| 119 |
+
# FLEX: 0 to 9
|
| 120 |
+
radar_coords = {
|
| 121 |
+
"LIPO": float(np.clip((logp - (-0.7)) / (5.0 - (-0.7)), 0.05, 1.2)),
|
| 122 |
+
"SIZE": float(np.clip((mw - 150) / (500 - 150), 0.05, 1.2)),
|
| 123 |
+
"POLAR": float(np.clip((tpsa - 20) / (130 - 20), 0.05, 1.2)),
|
| 124 |
+
"INSOLU": float(np.clip(max(0, logp) / 6.0, 0.05, 1.2)),
|
| 125 |
+
"INSATU": float(np.clip(fraction_csp3, 0.05, 1.2)),
|
| 126 |
+
"FLEX": float(np.clip(rot_bonds / 9.0, 0.05, 1.2))
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
return {
|
| 130 |
+
"descriptors": {
|
| 131 |
+
"Molecular Weight": mw,
|
| 132 |
+
"LogP": logp,
|
| 133 |
+
"TPSA": tpsa,
|
| 134 |
+
"H-Bond Donors": hbd,
|
| 135 |
+
"H-Bond Acceptors": hba,
|
| 136 |
+
"Rotatable Bonds": rot_bonds,
|
| 137 |
+
"Heavy Atoms": heavy_atoms,
|
| 138 |
+
"Aromatic Rings": aromatic_rings,
|
| 139 |
+
"Fraction Csp3": fraction_csp3,
|
| 140 |
+
"Molar Refractivity": molar_refractivity,
|
| 141 |
+
"QED": qed_score,
|
| 142 |
+
"SA Score (est)": sascore_est
|
| 143 |
+
},
|
| 144 |
+
"filters": {
|
| 145 |
+
"Lipinski": {
|
| 146 |
+
"passed": lipinski_pass,
|
| 147 |
+
"num_violations": len(lipinski_violations),
|
| 148 |
+
"violations": lipinski_violations
|
| 149 |
+
},
|
| 150 |
+
"Veber": {
|
| 151 |
+
"passed": veber_pass,
|
| 152 |
+
"num_violations": len(veber_violations),
|
| 153 |
+
"violations": veber_violations
|
| 154 |
+
},
|
| 155 |
+
"Ghose": {
|
| 156 |
+
"passed": ghose_pass,
|
| 157 |
+
"num_violations": len(ghose_violations),
|
| 158 |
+
"violations": ghose_violations
|
| 159 |
+
},
|
| 160 |
+
"Egan": {
|
| 161 |
+
"passed": egan_pass,
|
| 162 |
+
"num_violations": len(egan_violations),
|
| 163 |
+
"violations": egan_violations
|
| 164 |
+
}
|
| 165 |
+
},
|
| 166 |
+
"alerts": {
|
| 167 |
+
"PAINS": {
|
| 168 |
+
"has_alerts": len(pains_matches) > 0,
|
| 169 |
+
"count": len(pains_matches),
|
| 170 |
+
"descriptions": pains_matches
|
| 171 |
+
},
|
| 172 |
+
"Brenk": {
|
| 173 |
+
"has_alerts": len(brenk_matches) > 0,
|
| 174 |
+
"count": len(brenk_matches),
|
| 175 |
+
"descriptions": brenk_matches
|
| 176 |
+
}
|
| 177 |
+
},
|
| 178 |
+
"radar": radar_coords
|
| 179 |
+
}
|
src/analogs.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
In Silico Lead Optimization & Molecular Analog Generator.
|
| 3 |
+
Systematically generates bioisosteric and R-group substituted chemical analogs,
|
| 4 |
+
evaluates property shifts (Delta logS, Delta BBBP, Delta Tox), and discovers Pareto-optimal leads.
|
| 5 |
+
"""
|
| 6 |
+
from typing import List, Dict, Any, Optional
|
| 7 |
+
from rdkit import Chem, RDLogger
|
| 8 |
+
from rdkit.Chem import AllChem, Descriptors, QED
|
| 9 |
+
|
| 10 |
+
from src.admet import compute_admet_profile
|
| 11 |
+
from src.ensemble import predict_ensemble
|
| 12 |
+
|
| 13 |
+
RDLogger.DisableLog('rdApp.*')
|
| 14 |
+
|
| 15 |
+
# Common medicinal chemistry transformation rules
|
| 16 |
+
MEDCHEM_RULES = [
|
| 17 |
+
# Aromatic functional group replacements
|
| 18 |
+
{"name": "+Fluorine (-F)", "smarts": "[c:1][H]", "repl": "[c:1]F", "rationale": "Improves metabolic stability & lipophilicity"},
|
| 19 |
+
{"name": "+Chlorine (-Cl)", "smarts": "[c:1][H]", "repl": "[c:1]Cl", "rationale": "Enhances binding cavity packing"},
|
| 20 |
+
{"name": "+Methyl (-CH3)", "smarts": "[c:1][H]", "repl": "[c:1]C", "rationale": "Fills hydrophobic subpocket"},
|
| 21 |
+
{"name": "+Trifluoromethyl (-CF3)", "smarts": "[c:1][H]", "repl": "[c:1]C(F)(F)F", "rationale": "Strongly enhances lipophilic binding"},
|
| 22 |
+
{"name": "+Hydroxyl (-OH)", "smarts": "[c:1][H]", "repl": "[c:1]O", "rationale": "Improves aqueous solubility (H-bond donor)"},
|
| 23 |
+
{"name": "+Methoxy (-OCH3)", "smarts": "[c:1][H]", "repl": "[c:1]OC", "rationale": "H-bond acceptor without acidic proton"},
|
| 24 |
+
{"name": "+Amino (-NH2)", "smarts": "[c:1][H]", "repl": "[c:1]N", "rationale": "Basic center, enhances ionic interactions"},
|
| 25 |
+
{"name": "+Cyano (-C#N)", "smarts": "[c:1][H]", "repl": "[c:1]C#N", "rationale": "Linear polar group, bioisosteric to halide"},
|
| 26 |
+
{"name": "+Methylsulfonyl (-SO2CH3)", "smarts": "[c:1][H]", "repl": "[c:1]S(=O)(=O)C", "rationale": "High polarity, decreases lipophilicity"},
|
| 27 |
+
|
| 28 |
+
# Functional group bioisosteres
|
| 29 |
+
{"name": "Ester -> Amide", "smarts": "[C:1](=O)[O:2][C:3]", "repl": "[C:1](=O)[N:2][C:3]", "rationale": "Improves hydrolytic stability in plasma"},
|
| 30 |
+
{"name": "Acid -> Tetrazole (Bioisostere)", "smarts": "[C:1](=O)[OH]", "repl": "[C:1]c1nnnn1", "rationale": "Non-classical carboxylic acid bioisostere"},
|
| 31 |
+
{"name": "Phenol -> Fluoro-benzene", "smarts": "[c:1][OH]", "repl": "[c:1]F", "rationale": "Blocks Phase-II glucuronidation metabolism"}
|
| 32 |
+
]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def generate_analogs(parent_smiles: str, max_analogs: int = 15) -> List[Dict[str, Any]]:
|
| 36 |
+
"""
|
| 37 |
+
Generate chemically plausible analogs of the parent molecule.
|
| 38 |
+
|
| 39 |
+
Args:
|
| 40 |
+
parent_smiles: SMILES string of the lead compound.
|
| 41 |
+
max_analogs: Maximum number of distinct valid analogs to generate.
|
| 42 |
+
|
| 43 |
+
Returns:
|
| 44 |
+
List of generated analog dictionaries with SMILES and transformation details.
|
| 45 |
+
"""
|
| 46 |
+
mol = Chem.MolFromSmiles(parent_smiles)
|
| 47 |
+
if mol is None:
|
| 48 |
+
return []
|
| 49 |
+
|
| 50 |
+
# Explicit hydrogens for aromatic replacement rules
|
| 51 |
+
mol_with_h = Chem.AddHs(mol)
|
| 52 |
+
generated = {}
|
| 53 |
+
|
| 54 |
+
for rule in MEDCHEM_RULES:
|
| 55 |
+
rxn_smarts = f"({rule['smarts']})>>({rule['repl']})"
|
| 56 |
+
try:
|
| 57 |
+
rxn = AllChem.ReactionFromSmarts(rxn_smarts)
|
| 58 |
+
# Try on mol or mol_with_h
|
| 59 |
+
reactants = (mol_with_h,) if "[H]" in rule['smarts'] else (mol,)
|
| 60 |
+
products = rxn.RunReactants(reactants)
|
| 61 |
+
|
| 62 |
+
for prod_tuple in products:
|
| 63 |
+
prod = prod_tuple[0]
|
| 64 |
+
try:
|
| 65 |
+
prod = Chem.RemoveHs(prod)
|
| 66 |
+
Chem.SanitizeMol(prod)
|
| 67 |
+
smi = Chem.MolToSmiles(prod)
|
| 68 |
+
# Filter out identity or duplicates
|
| 69 |
+
if smi and smi != parent_smiles and smi not in generated:
|
| 70 |
+
generated[smi] = {
|
| 71 |
+
"smiles": smi,
|
| 72 |
+
"transformation": rule["name"],
|
| 73 |
+
"rationale": rule["rationale"]
|
| 74 |
+
}
|
| 75 |
+
if len(generated) >= max_analogs:
|
| 76 |
+
break
|
| 77 |
+
except Exception:
|
| 78 |
+
continue
|
| 79 |
+
except Exception:
|
| 80 |
+
continue
|
| 81 |
+
|
| 82 |
+
if len(generated) >= max_analogs:
|
| 83 |
+
break
|
| 84 |
+
|
| 85 |
+
return list(generated.values())
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def evaluate_lead_optimization(
|
| 89 |
+
parent_smiles: str,
|
| 90 |
+
target_dataset: str = "esol",
|
| 91 |
+
max_analogs: int = 12
|
| 92 |
+
) -> Dict[str, Any]:
|
| 93 |
+
"""
|
| 94 |
+
Generate analogs and evaluate property shifts against the parent compound.
|
| 95 |
+
"""
|
| 96 |
+
parent_admet = compute_admet_profile(parent_smiles)
|
| 97 |
+
parent_pred = predict_ensemble(parent_smiles, target_dataset)
|
| 98 |
+
parent_val = float(parent_pred["mean"][0])
|
| 99 |
+
|
| 100 |
+
analogs = generate_analogs(parent_smiles, max_analogs=max_analogs)
|
| 101 |
+
analog_results = []
|
| 102 |
+
|
| 103 |
+
for item in analogs:
|
| 104 |
+
smi = item["smiles"]
|
| 105 |
+
try:
|
| 106 |
+
admet = compute_admet_profile(smi)
|
| 107 |
+
pred = predict_ensemble(smi, target_dataset)
|
| 108 |
+
val = float(pred["mean"][0])
|
| 109 |
+
delta = val - parent_val
|
| 110 |
+
|
| 111 |
+
analog_results.append({
|
| 112 |
+
"SMILES": smi,
|
| 113 |
+
"Transformation": item["transformation"],
|
| 114 |
+
"Rationale": item["rationale"],
|
| 115 |
+
"Predicted Value": val,
|
| 116 |
+
"Delta": delta,
|
| 117 |
+
"QED": admet["descriptors"]["QED"],
|
| 118 |
+
"MW": admet["descriptors"]["Molecular Weight"],
|
| 119 |
+
"LogP": admet["descriptors"]["LogP"],
|
| 120 |
+
"TPSA": admet["descriptors"]["TPSA"],
|
| 121 |
+
"Lipinski Pass": admet["filters"]["Lipinski"]["passed"]
|
| 122 |
+
})
|
| 123 |
+
except Exception:
|
| 124 |
+
continue
|
| 125 |
+
|
| 126 |
+
return {
|
| 127 |
+
"parent_smiles": parent_smiles,
|
| 128 |
+
"parent_val": parent_val,
|
| 129 |
+
"parent_qed": parent_admet["descriptors"]["QED"],
|
| 130 |
+
"target_dataset": target_dataset,
|
| 131 |
+
"analogs": analog_results
|
| 132 |
+
}
|
src/batch_screen.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
High-Throughput Virtual Screening & Batch Profiling Engine.
|
| 3 |
+
Screens libraries of candidate SMILES across ESOL, BBBP, Tox21, and ADMET filters,
|
| 4 |
+
and ranks compounds using a Multi-Objective Drug Desirability Score.
|
| 5 |
+
"""
|
| 6 |
+
from typing import List, Dict, Any, Optional
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import numpy as np
|
| 9 |
+
from rdkit import Chem, RDLogger
|
| 10 |
+
from rdkit.Chem import Descriptors, QED
|
| 11 |
+
|
| 12 |
+
from src.admet import compute_admet_profile
|
| 13 |
+
from src.ensemble import predict_all_endpoints
|
| 14 |
+
|
| 15 |
+
RDLogger.DisableLog('rdApp.*')
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def calculate_desirability_score(
|
| 19 |
+
qed: float,
|
| 20 |
+
lipinski_pass: bool,
|
| 21 |
+
log_s: float,
|
| 22 |
+
max_tox_prob: float,
|
| 23 |
+
has_pains: bool,
|
| 24 |
+
prefer_bbb: bool = False,
|
| 25 |
+
bbb_prob: float = 0.5
|
| 26 |
+
) -> float:
|
| 27 |
+
"""
|
| 28 |
+
Compute a composite Drug Desirability Score between 0.0 and 100.0.
|
| 29 |
+
"""
|
| 30 |
+
# 1. QED component (0 - 30 pts)
|
| 31 |
+
score_qed = float(np.clip(qed, 0.0, 1.0)) * 30.0
|
| 32 |
+
|
| 33 |
+
# 2. Lipinski compliance (0 or 20 pts)
|
| 34 |
+
score_lipinski = 20.0 if lipinski_pass else 0.0
|
| 35 |
+
|
| 36 |
+
# 3. Solubility (0 - 20 pts) - optimal between -1 and -3
|
| 37 |
+
if log_s >= -2.0:
|
| 38 |
+
score_sol = 20.0
|
| 39 |
+
elif log_s >= -4.0:
|
| 40 |
+
score_sol = 15.0
|
| 41 |
+
elif log_s >= -6.0:
|
| 42 |
+
score_sol = 8.0
|
| 43 |
+
else:
|
| 44 |
+
score_sol = 2.0
|
| 45 |
+
|
| 46 |
+
# 4. Toxicity Safety (0 - 20 pts)
|
| 47 |
+
score_tox = max(0.0, (1.0 - max_tox_prob) * 20.0)
|
| 48 |
+
|
| 49 |
+
# 5. Assay Interference penalty (-10 pts if PAINS alert)
|
| 50 |
+
penalty_pains = -10.0 if has_pains else 0.0
|
| 51 |
+
|
| 52 |
+
# 6. BBB adjustment (0 - 10 pts)
|
| 53 |
+
if prefer_bbb:
|
| 54 |
+
score_bbb = bbb_prob * 10.0
|
| 55 |
+
else:
|
| 56 |
+
# Non-CNS drug preference (lower BBB is better)
|
| 57 |
+
score_bbb = (1.0 - bbb_prob) * 10.0
|
| 58 |
+
|
| 59 |
+
total = max(0.0, min(100.0, score_qed + score_lipinski + score_sol + score_tox + score_bbb + penalty_pains))
|
| 60 |
+
return round(total, 1)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def screen_batch_molecules(
|
| 64 |
+
smiles_list: List[str],
|
| 65 |
+
compound_names: Optional[List[str]] = None,
|
| 66 |
+
prefer_bbb: bool = False,
|
| 67 |
+
progress_callback: Optional[Any] = None
|
| 68 |
+
) -> pd.DataFrame:
|
| 69 |
+
"""
|
| 70 |
+
Screen a collection of SMILES strings and return a ranked DataFrame.
|
| 71 |
+
"""
|
| 72 |
+
records = []
|
| 73 |
+
total = len(smiles_list)
|
| 74 |
+
|
| 75 |
+
for idx, smi in enumerate(smiles_list):
|
| 76 |
+
smi = str(smi).strip()
|
| 77 |
+
if not smi:
|
| 78 |
+
continue
|
| 79 |
+
|
| 80 |
+
name = compound_names[idx] if compound_names and idx < len(compound_names) else f"Compound_{idx+1}"
|
| 81 |
+
mol = Chem.MolFromSmiles(smi)
|
| 82 |
+
if mol is None:
|
| 83 |
+
continue
|
| 84 |
+
|
| 85 |
+
try:
|
| 86 |
+
admet = compute_admet_profile(mol)
|
| 87 |
+
preds = predict_all_endpoints(smi)
|
| 88 |
+
|
| 89 |
+
esol_val = float(preds['esol']['mean'][0])
|
| 90 |
+
bbbp_prob = float(preds['bbbp']['mean'][0])
|
| 91 |
+
tox_probs = preds['tox21']['mean']
|
| 92 |
+
max_tox = float(np.max(tox_probs))
|
| 93 |
+
mean_tox = float(np.mean(tox_probs))
|
| 94 |
+
high_risk_tox_count = int(np.sum(tox_probs >= 0.5))
|
| 95 |
+
|
| 96 |
+
qed_score = admet['descriptors']['QED']
|
| 97 |
+
lip_pass = admet['filters']['Lipinski']['passed']
|
| 98 |
+
pains_alert = admet['alerts']['PAINS']['has_alerts']
|
| 99 |
+
|
| 100 |
+
desirability = calculate_desirability_score(
|
| 101 |
+
qed=qed_score,
|
| 102 |
+
lipinski_pass=lip_pass,
|
| 103 |
+
log_s=esol_val,
|
| 104 |
+
max_tox_prob=max_tox,
|
| 105 |
+
has_pains=pains_alert,
|
| 106 |
+
prefer_bbb=prefer_bbb,
|
| 107 |
+
bbb_prob=bbbp_prob
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
records.append({
|
| 111 |
+
"Compound ID": name,
|
| 112 |
+
"SMILES": smi,
|
| 113 |
+
"Desirability Score": desirability,
|
| 114 |
+
"QED": round(qed_score, 3),
|
| 115 |
+
"Solubility logS": round(esol_val, 2),
|
| 116 |
+
"BBB Probability": f"{bbbp_prob*100:.1f}%",
|
| 117 |
+
"Max Tox Risk": f"{max_tox*100:.1f}%",
|
| 118 |
+
"High Risk Assays": high_risk_tox_count,
|
| 119 |
+
"MW (Da)": round(admet['descriptors']['Molecular Weight'], 1),
|
| 120 |
+
"LogP": round(admet['descriptors']['LogP'], 2),
|
| 121 |
+
"TPSA (Ų)": round(admet['descriptors']['TPSA'], 1),
|
| 122 |
+
"Lipinski": "PASS" if lip_pass else f"FAIL ({admet['filters']['Lipinski']['num_violations']})",
|
| 123 |
+
"PAINS Alert": "YES" if pains_alert else "Clean"
|
| 124 |
+
})
|
| 125 |
+
except Exception:
|
| 126 |
+
continue
|
| 127 |
+
|
| 128 |
+
if progress_callback:
|
| 129 |
+
progress_callback((idx + 1) / total)
|
| 130 |
+
|
| 131 |
+
df = pd.DataFrame(records)
|
| 132 |
+
if not df.empty:
|
| 133 |
+
df = df.sort_values(by="Desirability Score", ascending=False).reset_index(drop=True)
|
| 134 |
+
return df
|
src/chemical_space.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Chemical Space Explorer & Structural Similarity Search Engine.
|
| 3 |
+
Projects molecules into 2D chemical space (PCA over Morgan fingerprints & descriptors)
|
| 4 |
+
and searches for nearest reference neighbors via Tanimoto structural similarity.
|
| 5 |
+
"""
|
| 6 |
+
from typing import Dict, List, Tuple, Any, Optional
|
| 7 |
+
import os
|
| 8 |
+
import numpy as np
|
| 9 |
+
import pandas as pd
|
| 10 |
+
from rdkit import Chem, DataStructs, RDLogger
|
| 11 |
+
from rdkit.Chem import AllChem, Descriptors
|
| 12 |
+
|
| 13 |
+
# Silence RDKit deprecation noise
|
| 14 |
+
RDLogger.DisableLog('rdApp.*')
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class ChemicalSpaceExplorer:
|
| 18 |
+
"""Manages chemical space projections and nearest neighbor retrieval."""
|
| 19 |
+
def __init__(self, dataset_csv: str = "data/raw/delaney-processed.csv", max_compounds: int = 1200):
|
| 20 |
+
self.dataset_csv = dataset_csv
|
| 21 |
+
self.df = None
|
| 22 |
+
self.fps = []
|
| 23 |
+
self.valid_indices = []
|
| 24 |
+
self.pca_mean = None
|
| 25 |
+
self.pca_components = None
|
| 26 |
+
self._load_and_index(max_compounds)
|
| 27 |
+
|
| 28 |
+
def _load_and_index(self, max_compounds: int):
|
| 29 |
+
if not os.path.exists(self.dataset_csv):
|
| 30 |
+
# Fallback if file missing
|
| 31 |
+
return
|
| 32 |
+
|
| 33 |
+
raw_df = pd.read_csv(self.dataset_csv)
|
| 34 |
+
if len(raw_df) > max_compounds:
|
| 35 |
+
raw_df = raw_df.sample(max_compounds, random_state=42).reset_index(drop=True)
|
| 36 |
+
|
| 37 |
+
smiles_col = 'smiles' if 'smiles' in raw_df.columns else raw_df.columns[-1]
|
| 38 |
+
name_col = 'Compound ID' if 'Compound ID' in raw_df.columns else ('name' if 'name' in raw_df.columns else None)
|
| 39 |
+
target_col = [c for c in raw_df.columns if 'solubility' in c.lower() or 'p_np' in c.lower()]
|
| 40 |
+
target_name = target_col[0] if target_col else None
|
| 41 |
+
|
| 42 |
+
valid_mols = []
|
| 43 |
+
valid_rows = []
|
| 44 |
+
fp_matrix = []
|
| 45 |
+
|
| 46 |
+
for idx, row in raw_df.iterrows():
|
| 47 |
+
smi = str(row[smiles_col]).strip()
|
| 48 |
+
mol = Chem.MolFromSmiles(smi)
|
| 49 |
+
if mol is not None:
|
| 50 |
+
# ECFP4 fingerprint (2048-bit)
|
| 51 |
+
fp = AllChem.GetMorganFingerprintAsBitVect(mol, radius=2, nBits=512)
|
| 52 |
+
arr = np.zeros((512,), dtype=np.float32)
|
| 53 |
+
DataStructs.ConvertToNumpyArray(fp, arr)
|
| 54 |
+
|
| 55 |
+
# Add basic descriptors for richer projection
|
| 56 |
+
mw = Descriptors.MolWt(mol) / 500.0
|
| 57 |
+
logp = Descriptors.MolLogP(mol) / 5.0
|
| 58 |
+
tpsa = Descriptors.TPSA(mol) / 150.0
|
| 59 |
+
feats = np.concatenate([arr, [mw, logp, tpsa]])
|
| 60 |
+
|
| 61 |
+
fp_matrix.append(feats)
|
| 62 |
+
self.fps.append(fp)
|
| 63 |
+
valid_mols.append(mol)
|
| 64 |
+
valid_rows.append({
|
| 65 |
+
"Compound ID": str(row[name_col]) if name_col and pd.notna(row[name_col]) else f"CMPD_{idx}",
|
| 66 |
+
"SMILES": smi,
|
| 67 |
+
"Target Value": float(row[target_name]) if target_name and pd.notna(row[target_name]) else 0.0
|
| 68 |
+
})
|
| 69 |
+
|
| 70 |
+
if not fp_matrix:
|
| 71 |
+
return
|
| 72 |
+
|
| 73 |
+
self.df = pd.DataFrame(valid_rows)
|
| 74 |
+
X = np.stack(fp_matrix, axis=0)
|
| 75 |
+
|
| 76 |
+
# Compute deterministic 2D PCA for projection
|
| 77 |
+
self.pca_mean = np.mean(X, axis=0)
|
| 78 |
+
X_centered = X - self.pca_mean
|
| 79 |
+
cov = np.cov(X_centered, rowvar=False)
|
| 80 |
+
eigenvalues, eigenvectors = np.linalg.eigh(cov)
|
| 81 |
+
idx = np.argsort(eigenvalues)[::-1]
|
| 82 |
+
self.pca_components = eigenvectors[:, idx[:2]]
|
| 83 |
+
|
| 84 |
+
coords = np.dot(X_centered, self.pca_components)
|
| 85 |
+
self.df["PC1"] = coords[:, 0]
|
| 86 |
+
self.df["PC2"] = coords[:, 1]
|
| 87 |
+
|
| 88 |
+
def project_molecule(self, smiles: str) -> Optional[Tuple[float, float]]:
|
| 89 |
+
"""Project a single SMILES query into the chemical space PC1-PC2 coordinates."""
|
| 90 |
+
if self.pca_components is None:
|
| 91 |
+
return None
|
| 92 |
+
|
| 93 |
+
mol = Chem.MolFromSmiles(smiles)
|
| 94 |
+
if mol is None:
|
| 95 |
+
return None
|
| 96 |
+
|
| 97 |
+
fp = AllChem.GetMorganFingerprintAsBitVect(mol, radius=2, nBits=512)
|
| 98 |
+
arr = np.zeros((512,), dtype=np.float32)
|
| 99 |
+
DataStructs.ConvertToNumpyArray(fp, arr)
|
| 100 |
+
mw = Descriptors.MolWt(mol) / 500.0
|
| 101 |
+
logp = Descriptors.MolLogP(mol) / 5.0
|
| 102 |
+
tpsa = Descriptors.TPSA(mol) / 150.0
|
| 103 |
+
feats = np.concatenate([arr, [mw, logp, tpsa]])
|
| 104 |
+
|
| 105 |
+
centered = feats - self.pca_mean
|
| 106 |
+
coords = np.dot(centered, self.pca_components)
|
| 107 |
+
return float(coords[0]), float(coords[1])
|
| 108 |
+
|
| 109 |
+
def find_nearest_neighbors(self, smiles: str, top_k: int = 5) -> List[Dict[str, Any]]:
|
| 110 |
+
"""Search reference compounds by Tanimoto similarity over Morgan fingerprints."""
|
| 111 |
+
mol = Chem.MolFromSmiles(smiles)
|
| 112 |
+
if mol is None or not self.fps:
|
| 113 |
+
return []
|
| 114 |
+
|
| 115 |
+
query_fp = AllChem.GetMorganFingerprintAsBitVect(mol, radius=2, nBits=512)
|
| 116 |
+
sims = DataStructs.BulkTanimotoSimilarity(query_fp, self.fps)
|
| 117 |
+
top_indices = np.argsort(sims)[::-1][:top_k]
|
| 118 |
+
|
| 119 |
+
results = []
|
| 120 |
+
for rank, i in enumerate(top_indices):
|
| 121 |
+
row = self.df.iloc[i]
|
| 122 |
+
results.append({
|
| 123 |
+
"Rank": rank + 1,
|
| 124 |
+
"Compound ID": row["Compound ID"],
|
| 125 |
+
"SMILES": row["SMILES"],
|
| 126 |
+
"Tanimoto Similarity": float(sims[i]),
|
| 127 |
+
"Target Value": row["Target Value"]
|
| 128 |
+
})
|
| 129 |
+
return results
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# Global singleton cache
|
| 133 |
+
_explorer_instances = {}
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def get_chemical_space_explorer(dataset_name: str = "esol") -> ChemicalSpaceExplorer:
|
| 137 |
+
"""Get or instantiate cached chemical space explorer."""
|
| 138 |
+
name = dataset_name.lower()
|
| 139 |
+
if name not in _explorer_instances:
|
| 140 |
+
if name == "esol":
|
| 141 |
+
path = "data/raw/delaney-processed.csv"
|
| 142 |
+
elif name == "bbbp":
|
| 143 |
+
path = "data/raw/BBBP.csv"
|
| 144 |
+
else:
|
| 145 |
+
path = "data/raw/delaney-processed.csv"
|
| 146 |
+
|
| 147 |
+
_explorer_instances[name] = ChemicalSpaceExplorer(dataset_csv=path)
|
| 148 |
+
return _explorer_instances[name]
|
src/conformers.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
3D Conformer Generation and Interactive Molecular Modeling.
|
| 3 |
+
Generates energetic 3D molecular conformations using ETKDG and MMFF94 force field,
|
| 4 |
+
and constructs py3Dmol visualizer HTML blocks for Streamlit components.
|
| 5 |
+
"""
|
| 6 |
+
from typing import Tuple, Optional
|
| 7 |
+
from rdkit import Chem
|
| 8 |
+
from rdkit.Chem import AllChem
|
| 9 |
+
import py3Dmol
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def generate_3d_conformer(smiles_or_mol: any, seed: int = 42) -> Tuple[Optional[Chem.Mol], Optional[str], Optional[float], str]:
|
| 13 |
+
"""
|
| 14 |
+
Generate an energy-minimized 3D conformer for a molecule.
|
| 15 |
+
|
| 16 |
+
Args:
|
| 17 |
+
smiles_or_mol: SMILES string or RDKit Mol object.
|
| 18 |
+
seed: Random seed for deterministic coordinate embedding.
|
| 19 |
+
|
| 20 |
+
Returns:
|
| 21 |
+
Tuple of (mol_3d, mol_block_str, energy_kcal_per_mol, status_message)
|
| 22 |
+
"""
|
| 23 |
+
if isinstance(smiles_or_mol, str):
|
| 24 |
+
mol = Chem.MolFromSmiles(smiles_or_mol)
|
| 25 |
+
else:
|
| 26 |
+
mol = Chem.Mol(smiles_or_mol)
|
| 27 |
+
|
| 28 |
+
if mol is None:
|
| 29 |
+
return None, None, None, "Invalid chemical structure"
|
| 30 |
+
|
| 31 |
+
mol_h = Chem.AddHs(mol)
|
| 32 |
+
|
| 33 |
+
# Use ETKDG embedding
|
| 34 |
+
params = AllChem.ETKDGv3() if hasattr(AllChem, 'ETKDGv3') else AllChem.ETKDG()
|
| 35 |
+
params.randomSeed = seed
|
| 36 |
+
embed_code = AllChem.EmbedMolecule(mol_h, params)
|
| 37 |
+
|
| 38 |
+
if embed_code != 0:
|
| 39 |
+
# Fallback with relaxed distance geometry
|
| 40 |
+
embed_code = AllChem.EmbedMolecule(mol_h, randomSeed=seed, useRandomCoords=True)
|
| 41 |
+
if embed_code != 0:
|
| 42 |
+
return None, None, None, "Failed to embed 3D coordinates"
|
| 43 |
+
|
| 44 |
+
# MMFF94 Force Field Optimization
|
| 45 |
+
energy = None
|
| 46 |
+
status = "MMFF94 optimized"
|
| 47 |
+
if AllChem.MMFFHasAllMoleculeParams(mol_h):
|
| 48 |
+
mmff_props = AllChem.MMFFGetMoleculeProperties(mol_h)
|
| 49 |
+
ff = AllChem.MMFFGetMoleculeForceField(mol_h, mmff_props)
|
| 50 |
+
if ff:
|
| 51 |
+
ff.Initialize()
|
| 52 |
+
ff.Minimize(maxIts=500)
|
| 53 |
+
energy = float(ff.CalcEnergy())
|
| 54 |
+
else:
|
| 55 |
+
# Fallback to Universal Force Field (UFF)
|
| 56 |
+
if AllChem.UFFHasAllMoleculeParams(mol_h):
|
| 57 |
+
ff = AllChem.UFFGetMoleculeForceField(mol_h)
|
| 58 |
+
if ff:
|
| 59 |
+
ff.Initialize()
|
| 60 |
+
ff.Minimize(maxIts=500)
|
| 61 |
+
energy = float(ff.CalcEnergy())
|
| 62 |
+
status = "UFF optimized (MMFF params unavailable for this element)"
|
| 63 |
+
|
| 64 |
+
mol_block = Chem.MolToMolBlock(mol_h)
|
| 65 |
+
return mol_h, mol_block, energy, status
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def render_3dmol_html(
|
| 69 |
+
mol_block: str,
|
| 70 |
+
style: str = "stick",
|
| 71 |
+
surface_type: str = "none",
|
| 72 |
+
spin: bool = False,
|
| 73 |
+
width: int = 500,
|
| 74 |
+
height: int = 400
|
| 75 |
+
) -> str:
|
| 76 |
+
"""
|
| 77 |
+
Generate self-contained HTML for interactive 3D molecular visualization using py3Dmol.
|
| 78 |
+
|
| 79 |
+
Args:
|
| 80 |
+
mol_block: MDL MolBlock representation of the 3D conformer.
|
| 81 |
+
style: Visual representation ('stick', 'ball_and_stick', 'sphere', 'wireframe').
|
| 82 |
+
surface_type: Molecular surface representation ('none', 'vdw', 'sas', 'ses').
|
| 83 |
+
spin: Whether to enable auto-rotation.
|
| 84 |
+
width: Viewer width in pixels.
|
| 85 |
+
height: Viewer height in pixels.
|
| 86 |
+
|
| 87 |
+
Returns:
|
| 88 |
+
HTML string suitable for st.components.v1.html.
|
| 89 |
+
"""
|
| 90 |
+
view = py3Dmol.view(width=width, height=height)
|
| 91 |
+
view.addModel(mol_block, "mol")
|
| 92 |
+
|
| 93 |
+
if style == "stick":
|
| 94 |
+
view.setStyle({'stick': {'radius': 0.18, 'colorscheme': 'cyanCarbon'}})
|
| 95 |
+
elif style == "ball_and_stick":
|
| 96 |
+
view.setStyle({
|
| 97 |
+
'stick': {'radius': 0.14, 'colorscheme': 'cyanCarbon'},
|
| 98 |
+
'sphere': {'scale': 0.28, 'colorscheme': 'cyanCarbon'}
|
| 99 |
+
})
|
| 100 |
+
elif style == "sphere":
|
| 101 |
+
view.setStyle({'sphere': {'scale': 0.85, 'colorscheme': 'cyanCarbon'}})
|
| 102 |
+
elif style == "wireframe":
|
| 103 |
+
view.setStyle({'line': {'linewidth': 2.0, 'colorscheme': 'cyanCarbon'}})
|
| 104 |
+
else:
|
| 105 |
+
view.setStyle({'stick': {}})
|
| 106 |
+
|
| 107 |
+
# Optional molecular surface
|
| 108 |
+
surf_map = {
|
| 109 |
+
'vdw': py3Dmol.VDW,
|
| 110 |
+
'sas': py3Dmol.SAS,
|
| 111 |
+
'ses': py3Dmol.SES
|
| 112 |
+
}
|
| 113 |
+
if surface_type.lower() in surf_map:
|
| 114 |
+
view.addSurface(surf_map[surface_type.lower()], {'opacity': 0.65, 'color': 'lightblue'})
|
| 115 |
+
|
| 116 |
+
if spin:
|
| 117 |
+
view.spin(True)
|
| 118 |
+
|
| 119 |
+
view.zoomTo()
|
| 120 |
+
view.setBackgroundColor('#0f172a') # Dark slate background for premium biotech feel
|
| 121 |
+
return view._make_html()
|
src/data.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Molecular Graph Featurization, Dataset Loading, and Scaffold Splitting.
|
| 3 |
+
Supports ESOL, BBBP, and Tox21 datasets with deterministic Bemis-Murcko splitting
|
| 4 |
+
and RDKit SMILES validation.
|
| 5 |
+
"""
|
| 6 |
+
import os
|
| 7 |
+
import gzip
|
| 8 |
+
import logging
|
| 9 |
+
import urllib.request
|
| 10 |
+
from collections import defaultdict
|
| 11 |
+
from typing import Dict, List, Optional, Tuple, Any
|
| 12 |
+
|
| 13 |
+
import pandas as pd
|
| 14 |
+
import numpy as np
|
| 15 |
+
import torch
|
| 16 |
+
from torch_geometric.data import Data
|
| 17 |
+
from torch_geometric.loader import DataLoader
|
| 18 |
+
import rdkit
|
| 19 |
+
from rdkit import Chem
|
| 20 |
+
from rdkit.Chem import AllChem
|
| 21 |
+
from rdkit.Chem.Scaffolds import MurckoScaffold
|
| 22 |
+
|
| 23 |
+
# Setup logger
|
| 24 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
| 25 |
+
logger = logging.getLogger(__name__)
|
| 26 |
+
|
| 27 |
+
# Allowed categorical sets for one-hot encodings
|
| 28 |
+
ALLOWED_ATOMS = [
|
| 29 |
+
'H', 'B', 'C', 'N', 'O', 'F', 'Na', 'Mg', 'Al', 'Si', 'P', 'S',
|
| 30 |
+
'Cl', 'K', 'Ca', 'Fe', 'Cu', 'Zn', 'Br', 'I', 'other'
|
| 31 |
+
]
|
| 32 |
+
ALLOWED_DEGREES = [0, 1, 2, 3, 4, 5, 6, 'other']
|
| 33 |
+
ALLOWED_CHARGES = [-2, -1, 0, 1, 2, 'other']
|
| 34 |
+
ALLOWED_HYBRIDIZATIONS = [
|
| 35 |
+
Chem.rdchem.HybridizationType.SP,
|
| 36 |
+
Chem.rdchem.HybridizationType.SP2,
|
| 37 |
+
Chem.rdchem.HybridizationType.SP3,
|
| 38 |
+
Chem.rdchem.HybridizationType.SP3D,
|
| 39 |
+
Chem.rdchem.HybridizationType.SP3D2,
|
| 40 |
+
'other'
|
| 41 |
+
]
|
| 42 |
+
ALLOWED_TOTAL_HS = [0, 1, 2, 3, 4, 'other']
|
| 43 |
+
|
| 44 |
+
ALLOWED_BONDS = [
|
| 45 |
+
Chem.rdchem.BondType.SINGLE,
|
| 46 |
+
Chem.rdchem.BondType.DOUBLE,
|
| 47 |
+
Chem.rdchem.BondType.TRIPLE,
|
| 48 |
+
Chem.rdchem.BondType.AROMATIC
|
| 49 |
+
]
|
| 50 |
+
ALLOWED_STEREO = [
|
| 51 |
+
Chem.rdchem.BondStereo.STEREONONE,
|
| 52 |
+
Chem.rdchem.BondStereo.STEREOANY,
|
| 53 |
+
Chem.rdchem.BondStereo.STEREOZ,
|
| 54 |
+
Chem.rdchem.BondStereo.STEREOE
|
| 55 |
+
]
|
| 56 |
+
|
| 57 |
+
# MoleculeNet dataset configs
|
| 58 |
+
DATASET_CONFIGS = {
|
| 59 |
+
'esol': {
|
| 60 |
+
'url': 'https://deepchemdata.s3-us-west-1.amazonaws.com/datasets/delaney-processed.csv',
|
| 61 |
+
'raw_filename': 'delaney-processed.csv',
|
| 62 |
+
'smiles_col': 'smiles',
|
| 63 |
+
'target_cols': ['measured log solubility in mols per litre'],
|
| 64 |
+
'task_type': 'regression',
|
| 65 |
+
'num_tasks': 1,
|
| 66 |
+
'metric': 'rmse'
|
| 67 |
+
},
|
| 68 |
+
'bbbp': {
|
| 69 |
+
'url': 'https://deepchemdata.s3-us-west-1.amazonaws.com/datasets/BBBP.csv',
|
| 70 |
+
'raw_filename': 'BBBP.csv',
|
| 71 |
+
'smiles_col': 'smiles',
|
| 72 |
+
'target_cols': ['p_np'],
|
| 73 |
+
'task_type': 'classification',
|
| 74 |
+
'num_tasks': 1,
|
| 75 |
+
'metric': 'roc_auc'
|
| 76 |
+
},
|
| 77 |
+
'tox21': {
|
| 78 |
+
'url': 'https://deepchemdata.s3-us-west-1.amazonaws.com/datasets/tox21.csv.gz',
|
| 79 |
+
'raw_filename': 'tox21.csv.gz',
|
| 80 |
+
'smiles_col': 'smiles',
|
| 81 |
+
'target_cols': [
|
| 82 |
+
'NR-AR', 'NR-AR-LBD', 'NR-AhR', 'NR-Aromatase', 'NR-ER', 'NR-ER-LBD',
|
| 83 |
+
'NR-PPAR-gamma', 'SR-ARE', 'SR-ATAD5', 'SR-HSE', 'SR-MMP', 'SR-p53'
|
| 84 |
+
],
|
| 85 |
+
'task_type': 'multitask_classification',
|
| 86 |
+
'num_tasks': 12,
|
| 87 |
+
'metric': 'roc_auc'
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def one_hot_encode(val: Any, allowed: List[Any]) -> List[float]:
|
| 93 |
+
"""Helper to one-hot encode a value given allowed categories with fallback to 'other'."""
|
| 94 |
+
vec = [0.0] * len(allowed)
|
| 95 |
+
if val in allowed:
|
| 96 |
+
vec[allowed.index(val)] = 1.0
|
| 97 |
+
else:
|
| 98 |
+
vec[-1] = 1.0
|
| 99 |
+
return vec
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def get_atom_features(atom: Chem.Atom) -> List[float]:
|
| 103 |
+
"""Extract node features for an RDKit Atom."""
|
| 104 |
+
return (
|
| 105 |
+
one_hot_encode(atom.GetSymbol(), ALLOWED_ATOMS) +
|
| 106 |
+
one_hot_encode(atom.GetTotalDegree(), ALLOWED_DEGREES) +
|
| 107 |
+
one_hot_encode(atom.GetFormalCharge(), ALLOWED_CHARGES) +
|
| 108 |
+
one_hot_encode(atom.GetHybridization(), ALLOWED_HYBRIDIZATIONS) +
|
| 109 |
+
[1.0 if atom.GetIsAromatic() else 0.0] +
|
| 110 |
+
one_hot_encode(atom.GetTotalNumHs(), ALLOWED_TOTAL_HS)
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def get_bond_features(bond: Chem.Bond) -> List[float]:
|
| 115 |
+
"""Extract edge features for an RDKit Bond."""
|
| 116 |
+
return (
|
| 117 |
+
one_hot_encode(bond.GetBondType(), ALLOWED_BONDS) +
|
| 118 |
+
[1.0 if bond.GetIsConjugated() else 0.0] +
|
| 119 |
+
[1.0 if bond.IsInRing() else 0.0] +
|
| 120 |
+
one_hot_encode(bond.GetStereo(), ALLOWED_STEREO)
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
# Calculate and expose dimensions
|
| 125 |
+
NODE_DIM = len(ALLOWED_ATOMS) + len(ALLOWED_DEGREES) + len(ALLOWED_CHARGES) + \
|
| 126 |
+
len(ALLOWED_HYBRIDIZATIONS) + 1 + len(ALLOWED_TOTAL_HS)
|
| 127 |
+
EDGE_DIM = len(ALLOWED_BONDS) + 1 + 1 + len(ALLOWED_STEREO)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def smiles_to_graph(smiles: str, y: Optional[torch.Tensor] = None) -> Optional[Data]:
|
| 131 |
+
"""
|
| 132 |
+
Convert a SMILES string to a PyG Data object.
|
| 133 |
+
Returns None if SMILES is invalid or cannot be parsed by RDKit.
|
| 134 |
+
"""
|
| 135 |
+
if not isinstance(smiles, str) or len(smiles.strip()) == 0:
|
| 136 |
+
return None
|
| 137 |
+
|
| 138 |
+
mol = Chem.MolFromSmiles(smiles)
|
| 139 |
+
if mol is None:
|
| 140 |
+
return None
|
| 141 |
+
|
| 142 |
+
# Compute node features
|
| 143 |
+
atom_feats = [get_atom_features(atom) for atom in mol.GetAtoms()]
|
| 144 |
+
x = torch.tensor(atom_feats, dtype=torch.float)
|
| 145 |
+
|
| 146 |
+
# Compute edges and edge features (undirected)
|
| 147 |
+
edge_indices = []
|
| 148 |
+
edge_attrs = []
|
| 149 |
+
for bond in mol.GetBonds():
|
| 150 |
+
i = bond.GetBeginAtomIdx()
|
| 151 |
+
j = bond.GetEndAtomIdx()
|
| 152 |
+
bond_feats = get_bond_features(bond)
|
| 153 |
+
|
| 154 |
+
# Both directions (i -> j) and (j -> i)
|
| 155 |
+
edge_indices.append([i, j])
|
| 156 |
+
edge_attrs.append(bond_feats)
|
| 157 |
+
edge_indices.append([j, i])
|
| 158 |
+
edge_attrs.append(bond_feats)
|
| 159 |
+
|
| 160 |
+
if len(edge_indices) > 0:
|
| 161 |
+
edge_index = torch.tensor(edge_indices, dtype=torch.long).t().contiguous()
|
| 162 |
+
edge_attr = torch.tensor(edge_attrs, dtype=torch.float)
|
| 163 |
+
else:
|
| 164 |
+
# Isolated atoms (no bonds)
|
| 165 |
+
edge_index = torch.empty((2, 0), dtype=torch.long)
|
| 166 |
+
edge_attr = torch.empty((0, EDGE_DIM), dtype=torch.float)
|
| 167 |
+
|
| 168 |
+
data = Data(
|
| 169 |
+
x=x,
|
| 170 |
+
edge_index=edge_index,
|
| 171 |
+
edge_attr=edge_attr,
|
| 172 |
+
smiles=smiles,
|
| 173 |
+
num_nodes=mol.GetNumAtoms()
|
| 174 |
+
)
|
| 175 |
+
if y is not None:
|
| 176 |
+
data.y = y
|
| 177 |
+
|
| 178 |
+
return data
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def smiles_to_fingerprint(smiles: str, radius: int = 2, n_bits: int = 2048) -> Optional[np.ndarray]:
|
| 182 |
+
"""Generate Morgan fingerprint (ECFP4) bit vector from SMILES."""
|
| 183 |
+
if not isinstance(smiles, str) or len(smiles.strip()) == 0:
|
| 184 |
+
return None
|
| 185 |
+
mol = Chem.MolFromSmiles(smiles)
|
| 186 |
+
if mol is None:
|
| 187 |
+
return None
|
| 188 |
+
fp = AllChem.GetMorganFingerprintAsBitVect(mol, radius=radius, nBits=n_bits)
|
| 189 |
+
arr = np.zeros((n_bits,), dtype=np.float32)
|
| 190 |
+
for bit in fp.GetOnBits():
|
| 191 |
+
arr[bit] = 1.0
|
| 192 |
+
return arr
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def generate_scaffold(smiles: str, include_chirality: bool = False) -> str:
|
| 196 |
+
"""Compute Bemis-Murcko scaffold string for a SMILES."""
|
| 197 |
+
mol = Chem.MolFromSmiles(smiles)
|
| 198 |
+
if mol is None:
|
| 199 |
+
return ""
|
| 200 |
+
scaffold = MurckoScaffold.MurckoScaffoldSmiles(smiles=smiles, includeChirality=include_chirality)
|
| 201 |
+
return scaffold
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def deterministic_scaffold_split(
|
| 205 |
+
data_list: List[Data],
|
| 206 |
+
train_frac: float = 0.8,
|
| 207 |
+
val_frac: float = 0.1,
|
| 208 |
+
test_frac: float = 0.1
|
| 209 |
+
) -> Tuple[List[Data], List[Data], List[Data]]:
|
| 210 |
+
"""
|
| 211 |
+
Split molecular dataset into train, val, test using deterministic Bemis-Murcko scaffold split.
|
| 212 |
+
Sorts scaffold groups by size (descending), then alphabetically by scaffold string.
|
| 213 |
+
"""
|
| 214 |
+
assert abs(train_frac + val_frac + test_frac - 1.0) < 1e-5, "Fractions must sum to 1.0"
|
| 215 |
+
|
| 216 |
+
# Group sample indices by scaffold
|
| 217 |
+
scaffold_to_indices = defaultdict(list)
|
| 218 |
+
for idx, data in enumerate(data_list):
|
| 219 |
+
scaffold = generate_scaffold(data.smiles)
|
| 220 |
+
scaffold_to_indices[scaffold].append(idx)
|
| 221 |
+
|
| 222 |
+
# Sort scaffold groups: primary key = -len(indices) (largest groups first), secondary = scaffold string
|
| 223 |
+
sorted_scaffold_groups = sorted(
|
| 224 |
+
scaffold_to_indices.items(),
|
| 225 |
+
key=lambda item: (-len(item[1]), item[0])
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
n_total = len(data_list)
|
| 229 |
+
train_cutoff = train_frac * n_total
|
| 230 |
+
val_cutoff = (train_frac + val_frac) * n_total
|
| 231 |
+
|
| 232 |
+
train_indices, val_indices, test_indices = [], [], []
|
| 233 |
+
|
| 234 |
+
for _, indices in sorted_scaffold_groups:
|
| 235 |
+
if len(train_indices) + len(indices) <= train_cutoff:
|
| 236 |
+
train_indices.extend(indices)
|
| 237 |
+
elif len(train_indices) + len(val_indices) + len(indices) <= val_cutoff:
|
| 238 |
+
val_indices.extend(indices)
|
| 239 |
+
else:
|
| 240 |
+
test_indices.extend(indices)
|
| 241 |
+
|
| 242 |
+
train_data = [data_list[i] for i in train_indices]
|
| 243 |
+
val_data = [data_list[i] for i in val_indices]
|
| 244 |
+
test_data = [data_list[i] for i in test_indices]
|
| 245 |
+
|
| 246 |
+
return train_data, val_data, test_data
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def download_raw_dataset(dataset_name: str, raw_dir: str) -> str:
|
| 250 |
+
"""Download raw dataset if not already present."""
|
| 251 |
+
config = DATASET_CONFIGS[dataset_name.lower()]
|
| 252 |
+
os.makedirs(raw_dir, exist_ok=True)
|
| 253 |
+
raw_path = os.path.join(raw_dir, config['raw_filename'])
|
| 254 |
+
|
| 255 |
+
if not os.path.exists(raw_path):
|
| 256 |
+
logger.info(f"Downloading {dataset_name} from {config['url']} -> {raw_path}")
|
| 257 |
+
urllib.request.urlretrieve(config['url'], raw_path)
|
| 258 |
+
logger.info(f"Successfully downloaded {dataset_name}.")
|
| 259 |
+
else:
|
| 260 |
+
logger.info(f"Found existing raw data file at {raw_path}")
|
| 261 |
+
|
| 262 |
+
return raw_path
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def load_and_preprocess_dataset(
|
| 266 |
+
dataset_name: str,
|
| 267 |
+
data_dir: str = 'data',
|
| 268 |
+
reload: bool = False
|
| 269 |
+
) -> Dict[str, Any]:
|
| 270 |
+
"""
|
| 271 |
+
Load, parse, validate, and featurize a MoleculeNet dataset.
|
| 272 |
+
Saves processed splits on disk for fast reload.
|
| 273 |
+
"""
|
| 274 |
+
dataset_name = dataset_name.lower()
|
| 275 |
+
if dataset_name not in DATASET_CONFIGS:
|
| 276 |
+
raise ValueError(f"Unknown dataset '{dataset_name}'. Allowed: {list(DATASET_CONFIGS.keys())}")
|
| 277 |
+
|
| 278 |
+
processed_dir = os.path.join(data_dir, 'processed')
|
| 279 |
+
raw_dir = os.path.join(data_dir, 'raw')
|
| 280 |
+
os.makedirs(processed_dir, exist_ok=True)
|
| 281 |
+
cache_path = os.path.join(processed_dir, f"{dataset_name}_processed.pt")
|
| 282 |
+
|
| 283 |
+
if os.path.exists(cache_path) and not reload:
|
| 284 |
+
logger.info(f"Loading cached dataset from {cache_path}")
|
| 285 |
+
return torch.load(cache_path, weights_only=False)
|
| 286 |
+
|
| 287 |
+
config = DATASET_CONFIGS[dataset_name]
|
| 288 |
+
raw_path = download_raw_dataset(dataset_name, raw_dir)
|
| 289 |
+
|
| 290 |
+
# Read dataframe (plain CSV or gzip)
|
| 291 |
+
if raw_path.endswith('.gz'):
|
| 292 |
+
with gzip.open(raw_path, 'rt') as f:
|
| 293 |
+
df = pd.read_csv(f)
|
| 294 |
+
else:
|
| 295 |
+
df = pd.read_csv(raw_path)
|
| 296 |
+
|
| 297 |
+
smiles_col = config['smiles_col']
|
| 298 |
+
target_cols = config['target_cols']
|
| 299 |
+
initial_rows = len(df)
|
| 300 |
+
|
| 301 |
+
valid_graphs: List[Data] = []
|
| 302 |
+
dropped_count = 0
|
| 303 |
+
|
| 304 |
+
for idx, row in df.iterrows():
|
| 305 |
+
smi = row[smiles_col]
|
| 306 |
+
# Target values
|
| 307 |
+
targets = [float(row[col]) if pd.notna(row[col]) else float('nan') for col in target_cols]
|
| 308 |
+
y_tensor = torch.tensor([targets], dtype=torch.float)
|
| 309 |
+
|
| 310 |
+
graph = smiles_to_graph(smi, y=y_tensor)
|
| 311 |
+
if graph is None:
|
| 312 |
+
dropped_count += 1
|
| 313 |
+
logger.debug(f"Row {idx}: Failed to parse SMILES '{smi}'. Dropped.")
|
| 314 |
+
else:
|
| 315 |
+
valid_graphs.append(graph)
|
| 316 |
+
|
| 317 |
+
final_size = len(valid_graphs)
|
| 318 |
+
logger.info(f"[{dataset_name.upper()}] Initial rows: {initial_rows} | Dropped invalid SMILES: {dropped_count} | Final valid size: {final_size}")
|
| 319 |
+
print(f"[{dataset_name.upper()}] Dataset processing complete: {initial_rows} rows -> {dropped_count} dropped -> {final_size} valid molecules.")
|
| 320 |
+
|
| 321 |
+
# Deterministic Bemis-Murcko scaffold split
|
| 322 |
+
train_data, val_data, test_data = deterministic_scaffold_split(valid_graphs, 0.8, 0.1, 0.1)
|
| 323 |
+
|
| 324 |
+
print(f"[{dataset_name.upper()}] Split sizes -> Train: {len(train_data)} ({len(train_data)/final_size*100:.1f}%), "
|
| 325 |
+
f"Val: {len(val_data)} ({len(val_data)/final_size*100:.1f}%), "
|
| 326 |
+
f"Test: {len(test_data)} ({len(test_data)/final_size*100:.1f}%)")
|
| 327 |
+
|
| 328 |
+
dataset_dict = {
|
| 329 |
+
'dataset_name': dataset_name,
|
| 330 |
+
'config': config,
|
| 331 |
+
'train': train_data,
|
| 332 |
+
'val': val_data,
|
| 333 |
+
'test': test_data,
|
| 334 |
+
'initial_size': initial_rows,
|
| 335 |
+
'dropped_count': dropped_count,
|
| 336 |
+
'final_size': final_size,
|
| 337 |
+
'node_dim': NODE_DIM,
|
| 338 |
+
'edge_dim': EDGE_DIM
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
# Save to disk cache
|
| 342 |
+
torch.save(dataset_dict, cache_path)
|
| 343 |
+
logger.info(f"Saved processed dataset to {cache_path}")
|
| 344 |
+
|
| 345 |
+
return dataset_dict
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
def get_dataloaders(
|
| 349 |
+
dataset_name: str,
|
| 350 |
+
batch_size: int = 32,
|
| 351 |
+
data_dir: str = 'data',
|
| 352 |
+
reload: bool = False,
|
| 353 |
+
quick: bool = False,
|
| 354 |
+
num_workers: int = 0
|
| 355 |
+
) -> Tuple[DataLoader, DataLoader, DataLoader, Dict[str, Any]]:
|
| 356 |
+
"""
|
| 357 |
+
Get PyG DataLoaders for train, val, test splits.
|
| 358 |
+
If quick=True, trims dataset sizes for fast smoke testing.
|
| 359 |
+
"""
|
| 360 |
+
data_bundle = load_and_preprocess_dataset(dataset_name, data_dir=data_dir, reload=reload)
|
| 361 |
+
|
| 362 |
+
train_data = data_bundle['train']
|
| 363 |
+
val_data = data_bundle['val']
|
| 364 |
+
test_data = data_bundle['test']
|
| 365 |
+
|
| 366 |
+
if quick:
|
| 367 |
+
train_data = train_data[:min(len(train_data), 100)]
|
| 368 |
+
val_data = val_data[:min(len(val_data), 20)]
|
| 369 |
+
test_data = test_data[:min(len(test_data), 20)]
|
| 370 |
+
logger.info(f"[QUICK MODE] Trimmed to: Train={len(train_data)}, Val={len(val_data)}, Test={len(test_data)}")
|
| 371 |
+
|
| 372 |
+
train_loader = DataLoader(train_data, batch_size=batch_size, shuffle=True, num_workers=num_workers)
|
| 373 |
+
val_loader = DataLoader(val_data, batch_size=batch_size, shuffle=False, num_workers=num_workers)
|
| 374 |
+
test_loader = DataLoader(test_data, batch_size=batch_size, shuffle=False, num_workers=num_workers)
|
| 375 |
+
|
| 376 |
+
return train_loader, val_loader, test_loader, data_bundle
|
src/ensemble.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Multi-Model Ensemble & Uncertainty Quantification Engine.
|
| 3 |
+
Executes consensus predictions across GCN, GAT, GIN, MPNN, and Baseline models
|
| 4 |
+
and quantifies epistemic uncertainty via standard deviation and confidence intervals.
|
| 5 |
+
"""
|
| 6 |
+
from typing import Dict, List, Any, Tuple, Optional
|
| 7 |
+
import os
|
| 8 |
+
import numpy as np
|
| 9 |
+
import torch
|
| 10 |
+
|
| 11 |
+
from src.data import smiles_to_graph, DATASET_CONFIGS
|
| 12 |
+
from src.evaluate import load_trained_model
|
| 13 |
+
|
| 14 |
+
MODEL_NAMES = ['gin', 'mpnn', 'gcn', 'gat', 'baseline']
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def predict_single_model(model: Any, model_name: str, graph: Any, dataset_name: str) -> np.ndarray:
|
| 18 |
+
"""Run inference for a single model and return output array (shape: [num_tasks])."""
|
| 19 |
+
if model_name == 'baseline':
|
| 20 |
+
pred = model.predict([graph])[0]
|
| 21 |
+
if np.ndim(pred) == 0:
|
| 22 |
+
pred = np.array([float(pred)])
|
| 23 |
+
return np.asarray(pred, dtype=np.float32)
|
| 24 |
+
|
| 25 |
+
model.eval()
|
| 26 |
+
with torch.no_grad():
|
| 27 |
+
out = model(graph.x, graph.edge_index, edge_attr=graph.edge_attr)
|
| 28 |
+
if dataset_name == 'esol':
|
| 29 |
+
val = out[0].cpu().numpy()
|
| 30 |
+
else:
|
| 31 |
+
# Classification or multi-task: convert logits to probabilities
|
| 32 |
+
val = torch.sigmoid(out)[0].cpu().numpy()
|
| 33 |
+
return np.asarray(val, dtype=np.float32)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def predict_ensemble(
|
| 37 |
+
smiles_or_graph: Any,
|
| 38 |
+
dataset_name: str,
|
| 39 |
+
loaded_models: Optional[Dict[str, Any]] = None,
|
| 40 |
+
checkpoint_dir: str = 'results/checkpoints'
|
| 41 |
+
) -> Dict[str, Any]:
|
| 42 |
+
"""
|
| 43 |
+
Run multi-model ensemble prediction for a specific dataset endpoint.
|
| 44 |
+
|
| 45 |
+
Args:
|
| 46 |
+
smiles_or_graph: SMILES string or PyG Data graph.
|
| 47 |
+
dataset_name: One of 'esol', 'bbbp', 'tox21'.
|
| 48 |
+
loaded_models: Optional preloaded dict of {model_name: model}.
|
| 49 |
+
checkpoint_dir: Directory containing saved checkpoints.
|
| 50 |
+
|
| 51 |
+
Returns:
|
| 52 |
+
Dictionary containing individual predictions, ensemble mean, std, CI, and agreement.
|
| 53 |
+
"""
|
| 54 |
+
ds = dataset_name.lower()
|
| 55 |
+
if isinstance(smiles_or_graph, str):
|
| 56 |
+
graph = smiles_to_graph(smiles_or_graph)
|
| 57 |
+
else:
|
| 58 |
+
graph = smiles_or_graph
|
| 59 |
+
|
| 60 |
+
if loaded_models is None:
|
| 61 |
+
loaded_models = {}
|
| 62 |
+
for m in MODEL_NAMES:
|
| 63 |
+
try:
|
| 64 |
+
mod, _ = load_trained_model(m, ds, checkpoint_dir=checkpoint_dir)
|
| 65 |
+
loaded_models[m] = mod
|
| 66 |
+
except Exception:
|
| 67 |
+
pass
|
| 68 |
+
|
| 69 |
+
if not loaded_models:
|
| 70 |
+
raise RuntimeError(f"No trained checkpoints found for dataset '{ds}'.")
|
| 71 |
+
|
| 72 |
+
predictions = {}
|
| 73 |
+
vals_list = []
|
| 74 |
+
|
| 75 |
+
for m_name, model in loaded_models.items():
|
| 76 |
+
try:
|
| 77 |
+
val = predict_single_model(model, m_name, graph, ds)
|
| 78 |
+
predictions[m_name] = val
|
| 79 |
+
vals_list.append(val)
|
| 80 |
+
except Exception as e:
|
| 81 |
+
continue
|
| 82 |
+
|
| 83 |
+
if not vals_list:
|
| 84 |
+
raise RuntimeError(f"Failed to obtain predictions for '{ds}'.")
|
| 85 |
+
|
| 86 |
+
stacked = np.stack(vals_list, axis=0) # Shape: (num_models, num_tasks)
|
| 87 |
+
mean_pred = np.mean(stacked, axis=0)
|
| 88 |
+
std_pred = np.std(stacked, axis=0)
|
| 89 |
+
|
| 90 |
+
# 95% Confidence bounds
|
| 91 |
+
ci_lower = mean_pred - 1.96 * std_pred
|
| 92 |
+
ci_upper = mean_pred + 1.96 * std_pred
|
| 93 |
+
|
| 94 |
+
# Assess model agreement
|
| 95 |
+
avg_std = float(np.mean(std_pred))
|
| 96 |
+
if ds == 'esol':
|
| 97 |
+
# logS standard deviation threshold
|
| 98 |
+
if avg_std < 0.25:
|
| 99 |
+
agreement = "High Consensus (Low Uncertainty)"
|
| 100 |
+
elif avg_std < 0.50:
|
| 101 |
+
agreement = "Moderate Consensus"
|
| 102 |
+
else:
|
| 103 |
+
agreement = "Divergent Predictions (High Uncertainty)"
|
| 104 |
+
else:
|
| 105 |
+
# Probability standard deviation threshold
|
| 106 |
+
if avg_std < 0.10:
|
| 107 |
+
agreement = "High Consensus (Low Uncertainty)"
|
| 108 |
+
elif avg_std < 0.20:
|
| 109 |
+
agreement = "Moderate Consensus"
|
| 110 |
+
else:
|
| 111 |
+
agreement = "Divergent Predictions (High Uncertainty)"
|
| 112 |
+
|
| 113 |
+
return {
|
| 114 |
+
"dataset": ds,
|
| 115 |
+
"task_names": DATASET_CONFIGS[ds]['target_cols'],
|
| 116 |
+
"individual": predictions,
|
| 117 |
+
"mean": mean_pred,
|
| 118 |
+
"std": std_pred,
|
| 119 |
+
"ci_lower": ci_lower,
|
| 120 |
+
"ci_upper": ci_upper,
|
| 121 |
+
"num_models": len(vals_list),
|
| 122 |
+
"agreement": agreement,
|
| 123 |
+
"uncertainty_score": avg_std
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def predict_all_endpoints(
|
| 128 |
+
smiles_or_graph: Any,
|
| 129 |
+
models_cache: Optional[Dict[str, Dict[str, Any]]] = None,
|
| 130 |
+
checkpoint_dir: str = 'results/checkpoints'
|
| 131 |
+
) -> Dict[str, Any]:
|
| 132 |
+
"""
|
| 133 |
+
Run full multi-endpoint prediction suite across ESOL, BBBP, and Tox21.
|
| 134 |
+
"""
|
| 135 |
+
results = {}
|
| 136 |
+
for ds in ['esol', 'bbbp', 'tox21']:
|
| 137 |
+
cache_ds = models_cache.get(ds) if models_cache else None
|
| 138 |
+
try:
|
| 139 |
+
res = predict_ensemble(
|
| 140 |
+
smiles_or_graph=smiles_or_graph,
|
| 141 |
+
dataset_name=ds,
|
| 142 |
+
loaded_models=cache_ds,
|
| 143 |
+
checkpoint_dir=checkpoint_dir
|
| 144 |
+
)
|
| 145 |
+
results[ds] = res
|
| 146 |
+
except Exception as e:
|
| 147 |
+
results[ds] = {"error": str(e)}
|
| 148 |
+
|
| 149 |
+
return results
|
src/evaluate.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Evaluation and Diagnostics Suite for GNNs and Classical Baselines.
|
| 3 |
+
Generates parity plots, ROC/PR curves, loss trajectories, t-SNE latent embeddings,
|
| 4 |
+
and compiles results/REPORT.md.
|
| 5 |
+
"""
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
|
| 9 |
+
# Ensure project root is in sys.path
|
| 10 |
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import logging
|
| 14 |
+
from typing import Dict, List, Any, Optional, Tuple
|
| 15 |
+
|
| 16 |
+
import yaml
|
| 17 |
+
import numpy as np
|
| 18 |
+
import torch
|
| 19 |
+
import matplotlib.pyplot as plt
|
| 20 |
+
import seaborn as sns
|
| 21 |
+
from sklearn.manifold import TSNE
|
| 22 |
+
from sklearn.metrics import (
|
| 23 |
+
mean_squared_error, mean_absolute_error, r2_score,
|
| 24 |
+
roc_curve, precision_recall_curve, roc_auc_score, average_precision_score
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
from src.data import get_dataloaders, NODE_DIM, EDGE_DIM
|
| 28 |
+
from src.models.gnn import build_gnn_model
|
| 29 |
+
from src.models.baseline import ClassicalFingerprintBaseline, extract_features
|
| 30 |
+
|
| 31 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
| 32 |
+
logger = logging.getLogger(__name__)
|
| 33 |
+
|
| 34 |
+
# Matplotlib styling for publication quality
|
| 35 |
+
plt.style.use('seaborn-v0_8-whitegrid' if 'seaborn-v0_8-whitegrid' in plt.style.available else 'default')
|
| 36 |
+
plt.rcParams['font.sans-serif'] = 'DejaVu Sans'
|
| 37 |
+
plt.rcParams['axes.edgecolor'] = '#cccccc'
|
| 38 |
+
plt.rcParams['axes.linewidth'] = 0.8
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def load_trained_model(
|
| 42 |
+
model_name: str,
|
| 43 |
+
dataset_name: str,
|
| 44 |
+
checkpoint_dir: str = 'results/checkpoints'
|
| 45 |
+
) -> Tuple[Any, Optional[Dict[str, Any]]]:
|
| 46 |
+
"""Load trained model checkpoint from disk."""
|
| 47 |
+
model_name = model_name.lower()
|
| 48 |
+
dataset_name = dataset_name.lower()
|
| 49 |
+
|
| 50 |
+
if model_name == 'baseline':
|
| 51 |
+
if os.environ.get("LOW_MEMORY_MODE") == "1" and dataset_name == "tox21":
|
| 52 |
+
# Avoid deserializing 650MB Tox21 Random Forest pickle on 512MB cloud free-tier VMs
|
| 53 |
+
raise RuntimeError("Tox21 RF baseline disabled in LOW_MEMORY_MODE to preserve RAM limit.")
|
| 54 |
+
ckpt_path = os.path.join(checkpoint_dir, f"{dataset_name}_baseline_best.pkl")
|
| 55 |
+
if not os.path.exists(ckpt_path):
|
| 56 |
+
raise FileNotFoundError(f"Baseline checkpoint not found at {ckpt_path}")
|
| 57 |
+
return ClassicalFingerprintBaseline.load(ckpt_path), None
|
| 58 |
+
else:
|
| 59 |
+
ckpt_path = os.path.join(checkpoint_dir, f"{dataset_name}_{model_name}_best.pt")
|
| 60 |
+
if not os.path.exists(ckpt_path):
|
| 61 |
+
raise FileNotFoundError(f"GNN checkpoint not found at {ckpt_path}")
|
| 62 |
+
payload = torch.load(ckpt_path, weights_only=False)
|
| 63 |
+
kwargs = payload['model_kwargs']
|
| 64 |
+
model = build_gnn_model(model_name=model_name, **kwargs)
|
| 65 |
+
model.load_state_dict(payload['model_state_dict'])
|
| 66 |
+
model.eval()
|
| 67 |
+
return model, payload
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def plot_loss_trajectories(dataset_name: str, history_dir: str = 'results/history', save_dir: str = 'results/plots'):
|
| 71 |
+
"""Plot training loss curves across GNN architectures."""
|
| 72 |
+
os.makedirs(save_dir, exist_ok=True)
|
| 73 |
+
models = ['gcn', 'gat', 'gin', 'mpnn']
|
| 74 |
+
colors = {'gcn': '#1f77b4', 'gat': '#ff7f0e', 'gin': '#2ca02c', 'mpnn': '#d62728'}
|
| 75 |
+
|
| 76 |
+
plt.figure(figsize=(8, 5))
|
| 77 |
+
has_data = False
|
| 78 |
+
|
| 79 |
+
for m in models:
|
| 80 |
+
hist_path = os.path.join(history_dir, f"{dataset_name}_{m}_history.json")
|
| 81 |
+
if os.path.exists(hist_path):
|
| 82 |
+
with open(hist_path, 'r') as f:
|
| 83 |
+
hist = json.load(f)
|
| 84 |
+
losses = hist.get('train_loss', [])
|
| 85 |
+
if losses:
|
| 86 |
+
plt.plot(range(1, len(losses) + 1), losses, label=m.upper(), color=colors.get(m, '#333333'), lw=2.0)
|
| 87 |
+
has_data = True
|
| 88 |
+
|
| 89 |
+
if has_data:
|
| 90 |
+
plt.xlabel("Epoch", fontsize=11, fontweight='bold')
|
| 91 |
+
plt.ylabel("Training Loss", fontsize=11, fontweight='bold')
|
| 92 |
+
plt.title(f"Training Loss Trajectory on {dataset_name.upper()} (CPU-Optimized)", fontsize=12, fontweight='bold')
|
| 93 |
+
plt.legend(frameon=True)
|
| 94 |
+
plt.tight_layout()
|
| 95 |
+
plot_path = os.path.join(save_dir, f"{dataset_name}_loss_curves.png")
|
| 96 |
+
plt.savefig(plot_path, dpi=300)
|
| 97 |
+
plt.close()
|
| 98 |
+
logger.info(f"Saved loss trajectory plot -> {plot_path}")
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def plot_parity(
|
| 102 |
+
dataset_name: str,
|
| 103 |
+
model_name: str = 'mpnn',
|
| 104 |
+
checkpoint_dir: str = 'results/checkpoints',
|
| 105 |
+
save_dir: str = 'results/plots'
|
| 106 |
+
):
|
| 107 |
+
"""Generate Parity Plot (Predicted vs Actual) for ESOL Regression."""
|
| 108 |
+
os.makedirs(save_dir, exist_ok=True)
|
| 109 |
+
_, _, test_loader, meta = get_dataloaders(dataset_name, batch_size=32)
|
| 110 |
+
test_data = meta['test']
|
| 111 |
+
|
| 112 |
+
model, _ = load_trained_model(model_name, dataset_name, checkpoint_dir)
|
| 113 |
+
|
| 114 |
+
if model_name == 'baseline':
|
| 115 |
+
preds = model.predict(test_data).flatten()
|
| 116 |
+
_, y_true = extract_features(test_data)
|
| 117 |
+
y_true = y_true.flatten()
|
| 118 |
+
else:
|
| 119 |
+
preds_list, targets_list = [], []
|
| 120 |
+
with torch.no_grad():
|
| 121 |
+
for batch in test_loader:
|
| 122 |
+
out = model(batch.x, batch.edge_index, edge_attr=batch.edge_attr, batch=batch.batch)
|
| 123 |
+
preds_list.append(out.view(-1).numpy())
|
| 124 |
+
targets_list.append(batch.y.view(-1).numpy())
|
| 125 |
+
preds = np.concatenate(preds_list)
|
| 126 |
+
y_true = np.concatenate(targets_list)
|
| 127 |
+
|
| 128 |
+
rmse = np.sqrt(mean_squared_error(y_true, preds))
|
| 129 |
+
r2 = r2_score(y_true, preds)
|
| 130 |
+
|
| 131 |
+
plt.figure(figsize=(6.5, 6))
|
| 132 |
+
plt.scatter(y_true, preds, alpha=0.7, color='#2b5c8f', edgecolors='k', linewidth=0.5, s=45, label='Test Compounds')
|
| 133 |
+
|
| 134 |
+
min_val = min(y_true.min(), preds.min()) - 0.5
|
| 135 |
+
max_val = max(y_true.max(), preds.max()) + 0.5
|
| 136 |
+
plt.plot([min_val, max_val], [min_val, max_val], 'r--', lw=1.8, label='Ideal 1:1 Parity')
|
| 137 |
+
|
| 138 |
+
plt.xlim(min_val, max_val)
|
| 139 |
+
plt.ylim(min_val, max_val)
|
| 140 |
+
plt.xlabel("Measured Aqueous Solubility logS (mol/L)", fontsize=11, fontweight='bold')
|
| 141 |
+
plt.ylabel(f"Predicted logS by {model_name.upper()} (mol/L)", fontsize=11, fontweight='bold')
|
| 142 |
+
plt.title(f"ESOL Parity Plot ({model_name.upper()} on Scaffold Test Split)\nRMSE: {rmse:.3f} | R²: {r2:.3f}", fontsize=12, fontweight='bold')
|
| 143 |
+
plt.legend(frameon=True)
|
| 144 |
+
plt.tight_layout()
|
| 145 |
+
|
| 146 |
+
plot_path = os.path.join(save_dir, f"{dataset_name}_parity_{model_name}.png")
|
| 147 |
+
plt.savefig(plot_path, dpi=300)
|
| 148 |
+
plt.close()
|
| 149 |
+
logger.info(f"Saved parity plot -> {plot_path}")
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def plot_roc_pr_curves(
|
| 153 |
+
dataset_name: str,
|
| 154 |
+
checkpoint_dir: str = 'results/checkpoints',
|
| 155 |
+
save_dir: str = 'results/plots'
|
| 156 |
+
):
|
| 157 |
+
"""Generate ROC and PR Curves for BBBP or Tox21 classification."""
|
| 158 |
+
os.makedirs(save_dir, exist_ok=True)
|
| 159 |
+
models = ['baseline', 'gcn', 'gat', 'gin', 'mpnn']
|
| 160 |
+
colors = {'baseline': '#7f7f7f', 'gcn': '#1f77b4', 'gat': '#ff7f0e', 'gin': '#2ca02c', 'mpnn': '#d62728'}
|
| 161 |
+
|
| 162 |
+
_, _, test_loader, meta = get_dataloaders(dataset_name, batch_size=32)
|
| 163 |
+
test_data = meta['test']
|
| 164 |
+
|
| 165 |
+
fig, (ax_roc, ax_pr) = plt.subplots(1, 2, figsize=(13, 5.5))
|
| 166 |
+
|
| 167 |
+
for m in models:
|
| 168 |
+
try:
|
| 169 |
+
model, _ = load_trained_model(m, dataset_name, checkpoint_dir)
|
| 170 |
+
except Exception:
|
| 171 |
+
continue
|
| 172 |
+
|
| 173 |
+
if m == 'baseline':
|
| 174 |
+
preds = model.predict(test_data)[:, 0]
|
| 175 |
+
_, y_true = extract_features(test_data)
|
| 176 |
+
y_true = y_true[:, 0]
|
| 177 |
+
else:
|
| 178 |
+
preds_list, targets_list = [], []
|
| 179 |
+
with torch.no_grad():
|
| 180 |
+
for batch in test_loader:
|
| 181 |
+
out = torch.sigmoid(model(batch.x, batch.edge_index, edge_attr=batch.edge_attr, batch=batch.batch))
|
| 182 |
+
preds_list.append(out[:, 0].numpy())
|
| 183 |
+
targets_list.append(batch.y[:, 0].numpy())
|
| 184 |
+
preds = np.concatenate(preds_list)
|
| 185 |
+
y_true = np.concatenate(targets_list)
|
| 186 |
+
|
| 187 |
+
mask = ~np.isnan(y_true)
|
| 188 |
+
y_eval = y_true[mask]
|
| 189 |
+
p_eval = preds[mask]
|
| 190 |
+
|
| 191 |
+
if len(np.unique(y_eval)) > 1:
|
| 192 |
+
fpr, tpr, _ = roc_curve(y_eval, p_eval)
|
| 193 |
+
roc_auc = roc_auc_score(y_eval, p_eval)
|
| 194 |
+
prec, rec, _ = precision_recall_curve(y_eval, p_eval)
|
| 195 |
+
pr_auc = average_precision_score(y_eval, p_eval)
|
| 196 |
+
|
| 197 |
+
c = colors.get(m, '#333333')
|
| 198 |
+
ax_roc.plot(fpr, tpr, label=f"{m.upper()} (AUC = {roc_auc:.3f})", color=c, lw=2.0)
|
| 199 |
+
ax_pr.plot(rec, prec, label=f"{m.upper()} (AUC = {pr_auc:.3f})", color=c, lw=2.0)
|
| 200 |
+
|
| 201 |
+
ax_roc.plot([0, 1], [0, 1], 'k--', lw=1.2, label='Random Chance')
|
| 202 |
+
ax_roc.set_xlabel("False Positive Rate", fontsize=11, fontweight='bold')
|
| 203 |
+
ax_roc.set_ylabel("True Positive Rate", fontsize=11, fontweight='bold')
|
| 204 |
+
ax_roc.set_title(f"ROC Curves on {dataset_name.upper()} Test Split", fontsize=12, fontweight='bold')
|
| 205 |
+
ax_roc.legend(loc='lower right', frameon=True)
|
| 206 |
+
|
| 207 |
+
ax_pr.set_xlabel("Recall", fontsize=11, fontweight='bold')
|
| 208 |
+
ax_pr.set_ylabel("Precision", fontsize=11, fontweight='bold')
|
| 209 |
+
ax_pr.set_title(f"Precision-Recall Curves on {dataset_name.upper()} Test Split", fontsize=12, fontweight='bold')
|
| 210 |
+
ax_pr.legend(loc='lower left', frameon=True)
|
| 211 |
+
|
| 212 |
+
plt.tight_layout()
|
| 213 |
+
plot_path = os.path.join(save_dir, f"{dataset_name}_roc_pr.png")
|
| 214 |
+
plt.savefig(plot_path, dpi=300)
|
| 215 |
+
plt.close()
|
| 216 |
+
logger.info(f"Saved ROC/PR curves -> {plot_path}")
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def plot_tsne_embeddings(
|
| 220 |
+
dataset_name: str,
|
| 221 |
+
model_name: str = 'mpnn',
|
| 222 |
+
checkpoint_dir: str = 'results/checkpoints',
|
| 223 |
+
save_dir: str = 'results/plots'
|
| 224 |
+
):
|
| 225 |
+
"""Extract latent embeddings and project into 2D with t-SNE."""
|
| 226 |
+
os.makedirs(save_dir, exist_ok=True)
|
| 227 |
+
_, _, test_loader, meta = get_dataloaders(dataset_name, batch_size=32)
|
| 228 |
+
|
| 229 |
+
try:
|
| 230 |
+
model, _ = load_trained_model(model_name, dataset_name, checkpoint_dir)
|
| 231 |
+
except Exception as e:
|
| 232 |
+
logger.warning(f"Could not load {model_name} for t-SNE: {e}")
|
| 233 |
+
return
|
| 234 |
+
|
| 235 |
+
embeddings_list, targets_list = [], []
|
| 236 |
+
with torch.no_grad():
|
| 237 |
+
for batch in test_loader:
|
| 238 |
+
emb = model.get_embedding(batch.x, batch.edge_index, edge_attr=batch.edge_attr, batch=batch.batch)
|
| 239 |
+
embeddings_list.append(emb.numpy())
|
| 240 |
+
targets_list.append(batch.y[:, 0].numpy())
|
| 241 |
+
|
| 242 |
+
embeddings = np.concatenate(embeddings_list, axis=0)
|
| 243 |
+
targets = np.concatenate(targets_list, axis=0)
|
| 244 |
+
|
| 245 |
+
# Filter out NaNs if any
|
| 246 |
+
mask = ~np.isnan(targets)
|
| 247 |
+
embeddings = embeddings[mask]
|
| 248 |
+
targets = targets[mask]
|
| 249 |
+
|
| 250 |
+
# Run t-SNE
|
| 251 |
+
perplexity = min(30, max(5, len(embeddings) // 4))
|
| 252 |
+
tsne = TSNE(n_components=2, perplexity=perplexity, random_state=42)
|
| 253 |
+
embeddings_2d = tsne.fit_transform(embeddings)
|
| 254 |
+
|
| 255 |
+
plt.figure(figsize=(7, 6))
|
| 256 |
+
if dataset_name.lower() == 'esol':
|
| 257 |
+
sc = plt.scatter(embeddings_2d[:, 0], embeddings_2d[:, 1], c=targets, cmap='viridis', s=50, alpha=0.85, edgecolors='k', linewidth=0.3)
|
| 258 |
+
cbar = plt.colorbar(sc)
|
| 259 |
+
cbar.set_label("Solubility logS (mol/L)", fontsize=10, fontweight='bold')
|
| 260 |
+
else:
|
| 261 |
+
# Binary classification
|
| 262 |
+
scatter_0 = plt.scatter(embeddings_2d[targets == 0, 0], embeddings_2d[targets == 0, 1], c='#3b82f6', s=50, alpha=0.8, edgecolors='k', linewidth=0.3, label='Negative (0)')
|
| 263 |
+
scatter_1 = plt.scatter(embeddings_2d[targets == 1, 0], embeddings_2d[targets == 1, 1], c='#ef4444', s=50, alpha=0.8, edgecolors='k', linewidth=0.3, label='Positive (1)')
|
| 264 |
+
plt.legend(frameon=True)
|
| 265 |
+
|
| 266 |
+
plt.title(f"t-SNE Latent Space ({model_name.upper()} on {dataset_name.upper()} Test Split)", fontsize=12, fontweight='bold')
|
| 267 |
+
plt.xlabel("t-SNE Dimension 1", fontsize=11, fontweight='bold')
|
| 268 |
+
plt.ylabel("t-SNE Dimension 2", fontsize=11, fontweight='bold')
|
| 269 |
+
plt.tight_layout()
|
| 270 |
+
|
| 271 |
+
plot_path = os.path.join(save_dir, f"{dataset_name}_tsne_{model_name}.png")
|
| 272 |
+
plt.savefig(plot_path, dpi=300)
|
| 273 |
+
plt.close()
|
| 274 |
+
logger.info(f"Saved t-SNE visualization -> {plot_path}")
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def generate_benchmark_report(
|
| 278 |
+
benchmark_results: Dict[str, Dict[str, Any]],
|
| 279 |
+
output_path: str = 'results/REPORT.md'
|
| 280 |
+
):
|
| 281 |
+
"""Compile comprehensive benchmark report across datasets and models."""
|
| 282 |
+
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
| 283 |
+
|
| 284 |
+
lines = [
|
| 285 |
+
"# Benchmark Report: Graph Neural Networks for Molecular Property Prediction",
|
| 286 |
+
"",
|
| 287 |
+
"## Executive Summary",
|
| 288 |
+
"This benchmark evaluates 5 distinct molecular property prediction architectures across 3 canonical MoleculeNet benchmarks using **strict Bemis-Murcko scaffold splitting (80/10/10)**.",
|
| 289 |
+
"All models were trained and benchmarked locally on an Intel CPU with 4 physical threads.",
|
| 290 |
+
"",
|
| 291 |
+
"---",
|
| 292 |
+
"",
|
| 293 |
+
"## Benchmark Results",
|
| 294 |
+
""
|
| 295 |
+
]
|
| 296 |
+
|
| 297 |
+
for dataset_name, models_dict in benchmark_results.items():
|
| 298 |
+
lines.append(f"### Dataset: `{dataset_name.upper()}`")
|
| 299 |
+
if dataset_name.lower() == 'esol':
|
| 300 |
+
lines.append("| Model Architecture | Test RMSE (mol/L) ↓ | Test MAE (mol/L) ↓ | Test R² ↑ | Train Time (s) |")
|
| 301 |
+
lines.append("| :--- | :--- | :--- | :--- | :--- |")
|
| 302 |
+
for m, data in models_dict.items():
|
| 303 |
+
agg = data['aggregated_test_metrics']
|
| 304 |
+
rmse_str = f"{agg['rmse']['mean']:.3f} ± {agg['rmse']['std']:.3f}"
|
| 305 |
+
mae_str = f"{agg['mae']['mean']:.3f} ± {agg['mae']['std']:.3f}"
|
| 306 |
+
r2_str = f"{agg['r2']['mean']:.3f} ± {agg['r2']['std']:.3f}"
|
| 307 |
+
time_str = f"{data['mean_train_time_sec']:.1f}s"
|
| 308 |
+
lines.append(f"| **{m.upper()}** | {rmse_str} | {mae_str} | {r2_str} | {time_str} |")
|
| 309 |
+
else:
|
| 310 |
+
lines.append("| Model Architecture | Test ROC-AUC ↑ | Test PR-AUC ↑ | Train Time (s) |")
|
| 311 |
+
lines.append("| :--- | :--- | :--- | :--- |")
|
| 312 |
+
for m, data in models_dict.items():
|
| 313 |
+
agg = data['aggregated_test_metrics']
|
| 314 |
+
roc_str = f"{agg['roc_auc']['mean']:.3f} ± {agg['roc_auc']['std']:.3f}"
|
| 315 |
+
pr_str = f"{agg['pr_auc']['mean']:.3f} ± {agg['pr_auc']['std']:.3f}"
|
| 316 |
+
time_str = f"{data['mean_train_time_sec']:.1f}s"
|
| 317 |
+
lines.append(f"| **{m.upper()}** | {roc_str} | {pr_str} | {time_str} |")
|
| 318 |
+
lines.append("")
|
| 319 |
+
|
| 320 |
+
lines.extend([
|
| 321 |
+
"---",
|
| 322 |
+
"",
|
| 323 |
+
"## Inductive Biases & Architectural Insights",
|
| 324 |
+
"",
|
| 325 |
+
"1. **Message Passing (MPNN)**: Leverages edge attributes (bond conjugation, ring membership, stereochemistry). Performs exceptionally well on solubility where ring strain and bond conjugation directly dictate crystal lattice energy and water solvation.",
|
| 326 |
+
"2. **Attention Mechanism (GAT)**: Dynamically re-weights atomic neighbor interactions through multi-head self-attention. Especially effective for recognizing functional pharmacophores without rigid topology assumptions.",
|
| 327 |
+
"3. **Graph Isomorphism (GIN)**: Maximally expressive for distinguishing graph topologies via multi-layer perceptron aggregations.",
|
| 328 |
+
"4. **Classical Fingerprint Baseline (Random Forest + ECFP4)**: Strong competitive baseline that serves as a sanity check. While fast, it ignores global 3D topology and continuous atom-bond interactions.",
|
| 329 |
+
"",
|
| 330 |
+
"---",
|
| 331 |
+
"",
|
| 332 |
+
"## Diagnostic Plots",
|
| 333 |
+
"- **Training Trajectories**: `results/plots/{dataset}_loss_curves.png`",
|
| 334 |
+
"- **Parity Plots**: `results/plots/esol_parity_{model}.png`",
|
| 335 |
+
"- **ROC & PR Curves**: `results/plots/{dataset}_roc_pr.png`",
|
| 336 |
+
"- **t-SNE Embeddings**: `results/plots/{dataset}_tsne_{model}.png`"
|
| 337 |
+
])
|
| 338 |
+
|
| 339 |
+
with open(output_path, 'w') as f:
|
| 340 |
+
f.write("\n".join(lines))
|
| 341 |
+
|
| 342 |
+
logger.info(f"Report compiled successfully -> {output_path}")
|
src/explain.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Model Explainability Suite using GNNExplainer and RDKit 2D Substructure Rendering.
|
| 3 |
+
Supports both single-task and multi-task models, with edge-aware and non-edge GNNs.
|
| 4 |
+
"""
|
| 5 |
+
import os
|
| 6 |
+
import sys
|
| 7 |
+
|
| 8 |
+
# Ensure project root is in sys.path
|
| 9 |
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
| 10 |
+
|
| 11 |
+
import logging
|
| 12 |
+
import argparse
|
| 13 |
+
from typing import Dict, List, Optional, Tuple, Any, Union
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
import torch
|
| 17 |
+
import torch.nn as nn
|
| 18 |
+
from torch_geometric.explain import Explainer, GNNExplainer, ModelConfig
|
| 19 |
+
from rdkit import Chem
|
| 20 |
+
from rdkit.Chem.Draw import rdMolDraw2D
|
| 21 |
+
import matplotlib.pyplot as plt
|
| 22 |
+
|
| 23 |
+
from src.data import smiles_to_graph
|
| 24 |
+
from src.models.gnn import build_gnn_model
|
| 25 |
+
|
| 26 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class TaskSpecificWrapper(nn.Module):
|
| 31 |
+
"""Wraps a multi-task GNN to isolate a single task output for GNNExplainer."""
|
| 32 |
+
def __init__(self, base_model: nn.Module, task_idx: int = 0):
|
| 33 |
+
super().__init__()
|
| 34 |
+
self.base_model = base_model
|
| 35 |
+
self.task_idx = task_idx
|
| 36 |
+
|
| 37 |
+
def forward(
|
| 38 |
+
self,
|
| 39 |
+
x: torch.Tensor,
|
| 40 |
+
edge_index: torch.Tensor,
|
| 41 |
+
edge_attr: Optional[torch.Tensor] = None,
|
| 42 |
+
batch: Optional[torch.Tensor] = None
|
| 43 |
+
) -> torch.Tensor:
|
| 44 |
+
out = self.base_model(x, edge_index, edge_attr=edge_attr, batch=batch)
|
| 45 |
+
if out.ndim == 1:
|
| 46 |
+
return out.view(-1, 1)
|
| 47 |
+
return out[:, self.task_idx:self.task_idx + 1]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def explain_molecule(
|
| 51 |
+
model: nn.Module,
|
| 52 |
+
smiles: str,
|
| 53 |
+
task_type: str = 'regression',
|
| 54 |
+
task_idx: int = 0,
|
| 55 |
+
num_tasks: int = 1,
|
| 56 |
+
num_epochs: int = 50,
|
| 57 |
+
device: str = 'cpu'
|
| 58 |
+
) -> Tuple[np.ndarray, np.ndarray, float]:
|
| 59 |
+
"""
|
| 60 |
+
Explain a molecular graph prediction using GNNExplainer.
|
| 61 |
+
Returns:
|
| 62 |
+
atom_importances: np.ndarray of shape (num_atoms,)
|
| 63 |
+
edge_importances: np.ndarray of shape (num_edges,)
|
| 64 |
+
pred_value: float scalar prediction
|
| 65 |
+
"""
|
| 66 |
+
model.eval()
|
| 67 |
+
graph = smiles_to_graph(smiles)
|
| 68 |
+
if graph is None:
|
| 69 |
+
raise ValueError(f"Invalid SMILES string: '{smiles}'")
|
| 70 |
+
|
| 71 |
+
graph = graph.to(device)
|
| 72 |
+
|
| 73 |
+
# Determine prediction
|
| 74 |
+
with torch.no_grad():
|
| 75 |
+
raw_pred = model(graph.x, graph.edge_index, edge_attr=graph.edge_attr)
|
| 76 |
+
if task_type != 'regression':
|
| 77 |
+
raw_pred = torch.sigmoid(raw_pred)
|
| 78 |
+
pred_scalar = float(raw_pred[0, task_idx].item()) if raw_pred.ndim > 1 else float(raw_pred[0].item())
|
| 79 |
+
|
| 80 |
+
# Wrap model if multi-task
|
| 81 |
+
if num_tasks > 1:
|
| 82 |
+
wrapped_model = TaskSpecificWrapper(model, task_idx=task_idx)
|
| 83 |
+
else:
|
| 84 |
+
wrapped_model = model
|
| 85 |
+
|
| 86 |
+
mode = 'regression' if task_type == 'regression' else 'binary_classification'
|
| 87 |
+
explainer = Explainer(
|
| 88 |
+
model=wrapped_model,
|
| 89 |
+
algorithm=GNNExplainer(epochs=num_epochs),
|
| 90 |
+
explanation_type='model',
|
| 91 |
+
node_mask_type='attributes',
|
| 92 |
+
edge_mask_type='object' if graph.edge_index.size(1) > 0 else None,
|
| 93 |
+
model_config=ModelConfig(
|
| 94 |
+
mode=mode,
|
| 95 |
+
task_level='graph',
|
| 96 |
+
return_type='raw'
|
| 97 |
+
)
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
explanation = explainer(
|
| 101 |
+
x=graph.x,
|
| 102 |
+
edge_index=graph.edge_index,
|
| 103 |
+
edge_attr=graph.edge_attr
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
# Atom importance: sum across feature attributes
|
| 107 |
+
if explanation.node_mask is not None:
|
| 108 |
+
atom_scores = explanation.node_mask.sum(dim=-1).detach().cpu().numpy()
|
| 109 |
+
else:
|
| 110 |
+
atom_scores = np.ones(graph.num_nodes, dtype=np.float32)
|
| 111 |
+
|
| 112 |
+
# Edge importance
|
| 113 |
+
if explanation.edge_mask is not None and graph.edge_index.size(1) > 0:
|
| 114 |
+
edge_scores = explanation.edge_mask.detach().cpu().numpy()
|
| 115 |
+
else:
|
| 116 |
+
edge_scores = np.zeros(graph.edge_index.size(1), dtype=np.float32)
|
| 117 |
+
|
| 118 |
+
return atom_scores, edge_scores, pred_scalar
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def render_molecule_explanation(
|
| 122 |
+
smiles: str,
|
| 123 |
+
atom_importances: np.ndarray,
|
| 124 |
+
width: int = 500,
|
| 125 |
+
height: int = 400,
|
| 126 |
+
colormap_name: str = 'Reds',
|
| 127 |
+
output_path: Optional[str] = None
|
| 128 |
+
) -> bytes:
|
| 129 |
+
"""
|
| 130 |
+
Render 2D molecular structure with atom-level importance heatmaps.
|
| 131 |
+
Outputs high-resolution PNG bytes and optionally saves to disk.
|
| 132 |
+
"""
|
| 133 |
+
mol = Chem.MolFromSmiles(smiles)
|
| 134 |
+
if mol is None:
|
| 135 |
+
raise ValueError(f"Invalid SMILES string '{smiles}'")
|
| 136 |
+
|
| 137 |
+
num_atoms = mol.GetNumAtoms()
|
| 138 |
+
if len(atom_importances) != num_atoms:
|
| 139 |
+
atom_scores = np.resize(atom_importances, num_atoms)
|
| 140 |
+
else:
|
| 141 |
+
atom_scores = np.array(atom_importances, dtype=np.float32)
|
| 142 |
+
|
| 143 |
+
# Normalize scores between 0.15 and 0.95 for clear visual contrast
|
| 144 |
+
min_s = float(atom_scores.min())
|
| 145 |
+
max_s = float(atom_scores.max())
|
| 146 |
+
range_s = max_s - min_s if max_s > min_s else 1.0
|
| 147 |
+
norm_scores = 0.15 + 0.8 * ((atom_scores - min_s) / range_s)
|
| 148 |
+
|
| 149 |
+
cmap = plt.colormaps[colormap_name]
|
| 150 |
+
atom_colors = {}
|
| 151 |
+
radii = {}
|
| 152 |
+
|
| 153 |
+
for i in range(num_atoms):
|
| 154 |
+
rgba = cmap(float(norm_scores[i]))
|
| 155 |
+
atom_colors[i] = (float(rgba[0]), float(rgba[1]), float(rgba[2]))
|
| 156 |
+
radii[i] = 0.35 + 0.25 * float(norm_scores[i])
|
| 157 |
+
|
| 158 |
+
drawer = rdMolDraw2D.MolDraw2DCairo(width, height)
|
| 159 |
+
draw_options = drawer.drawOptions()
|
| 160 |
+
draw_options.useBWAtomPalette()
|
| 161 |
+
draw_options.scaleBondWidth = True
|
| 162 |
+
draw_options.bondLineWidth = 2
|
| 163 |
+
|
| 164 |
+
drawer.DrawMolecule(
|
| 165 |
+
mol,
|
| 166 |
+
highlightAtoms=list(atom_colors.keys()),
|
| 167 |
+
highlightAtomColors=atom_colors,
|
| 168 |
+
highlightAtomRadii=radii
|
| 169 |
+
)
|
| 170 |
+
drawer.FinishDrawing()
|
| 171 |
+
png_bytes = drawer.GetDrawingText()
|
| 172 |
+
|
| 173 |
+
if output_path is not None:
|
| 174 |
+
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
| 175 |
+
with open(output_path, 'wb') as f:
|
| 176 |
+
f.write(png_bytes)
|
| 177 |
+
logger.info(f"Saved explanation rendering -> {output_path}")
|
| 178 |
+
|
| 179 |
+
return png_bytes
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
if __name__ == "__main__":
|
| 183 |
+
parser = argparse.ArgumentParser(description="Explain GNN molecular property prediction")
|
| 184 |
+
parser.add_argument("--smiles", type=str, default="CC(=O)OC1=CC=CC=C1C(=O)O", help="SMILES to explain")
|
| 185 |
+
parser.add_argument("--model", type=str, default="gcn", help="Model name (gcn, gat, gin, mpnn)")
|
| 186 |
+
parser.add_argument("--dataset", type=str, default="esol", help="Dataset name (esol, bbbp, tox21)")
|
| 187 |
+
parser.add_argument("--task_idx", type=int, default=0, help="Task index to explain (0-11 for Tox21)")
|
| 188 |
+
parser.add_argument("--output", type=str, default="results/plots/explanation_demo.png", help="Output PNG path")
|
| 189 |
+
args = parser.parse_args()
|
| 190 |
+
|
| 191 |
+
from src.evaluate import load_trained_model
|
| 192 |
+
model, payload = load_trained_model(args.model, args.dataset)
|
| 193 |
+
num_tasks = payload['num_tasks'] if payload else 1
|
| 194 |
+
task_type = 'regression' if args.dataset == 'esol' else ('multitask_classification' if args.dataset == 'tox21' else 'classification')
|
| 195 |
+
|
| 196 |
+
atom_imp, edge_imp, pred = explain_molecule(
|
| 197 |
+
model=model,
|
| 198 |
+
smiles=args.smiles,
|
| 199 |
+
task_type=task_type,
|
| 200 |
+
task_idx=args.task_idx,
|
| 201 |
+
num_tasks=num_tasks,
|
| 202 |
+
num_epochs=30
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
print(f"Prediction for {args.smiles}: {pred:.4f}")
|
| 206 |
+
print(f"Top 3 important atom indices: {np.argsort(atom_imp)[-3:][::-1]}")
|
| 207 |
+
render_molecule_explanation(args.smiles, atom_imp, output_path=args.output)
|
src/models/__init__.py
ADDED
|
File without changes
|
src/models/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (146 Bytes). View file
|
|
|
src/models/__pycache__/baseline.cpython-310.pyc
ADDED
|
Binary file (6.65 kB). View file
|
|
|
src/models/__pycache__/gnn.cpython-310.pyc
ADDED
|
Binary file (8.21 kB). View file
|
|
|
src/models/baseline.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Classical Machine Learning Baselines for Molecular Property Prediction.
|
| 3 |
+
Uses 2048-bit Morgan Fingerprints (ECFP4) with Random Forest and Gradient Boosting.
|
| 4 |
+
"""
|
| 5 |
+
import os
|
| 6 |
+
import sys
|
| 7 |
+
|
| 8 |
+
# Ensure project root is in sys.path
|
| 9 |
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..')))
|
| 10 |
+
|
| 11 |
+
import pickle
|
| 12 |
+
import logging
|
| 13 |
+
from typing import Dict, List, Optional, Tuple, Any, Union
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
import torch
|
| 17 |
+
from torch_geometric.data import Data
|
| 18 |
+
from sklearn.ensemble import RandomForestRegressor, RandomForestClassifier, GradientBoostingRegressor
|
| 19 |
+
from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score, roc_auc_score, average_precision_score
|
| 20 |
+
from rdkit import Chem
|
| 21 |
+
from rdkit.Chem import Descriptors, AllChem
|
| 22 |
+
|
| 23 |
+
from src.data import smiles_to_fingerprint
|
| 24 |
+
|
| 25 |
+
logger = logging.getLogger(__name__)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def extract_features(data_list: List[Data], n_bits: int = 2048, use_descriptors: bool = True) -> Tuple[np.ndarray, np.ndarray]:
|
| 29 |
+
"""
|
| 30 |
+
Extract Morgan fingerprints (ECFP4) and optional physicochemical descriptors for a list of PyG Data objects.
|
| 31 |
+
Returns:
|
| 32 |
+
X: np.ndarray of shape (num_samples, num_features)
|
| 33 |
+
y: np.ndarray of shape (num_samples, num_tasks)
|
| 34 |
+
"""
|
| 35 |
+
X_list = []
|
| 36 |
+
y_list = []
|
| 37 |
+
|
| 38 |
+
for data in data_list:
|
| 39 |
+
smiles = data.smiles
|
| 40 |
+
mol = Chem.MolFromSmiles(smiles)
|
| 41 |
+
if mol is None:
|
| 42 |
+
continue
|
| 43 |
+
|
| 44 |
+
# 2048-bit Morgan fingerprint
|
| 45 |
+
fp = smiles_to_fingerprint(smiles, radius=2, n_bits=n_bits)
|
| 46 |
+
if fp is None:
|
| 47 |
+
continue
|
| 48 |
+
|
| 49 |
+
if use_descriptors:
|
| 50 |
+
# Add MolWt, LogP, TPSA, NumRotatableBonds, HBD, HBA
|
| 51 |
+
desc = np.array([
|
| 52 |
+
Descriptors.MolWt(mol),
|
| 53 |
+
Descriptors.MolLogP(mol),
|
| 54 |
+
Descriptors.TPSA(mol),
|
| 55 |
+
Descriptors.NumRotatableBonds(mol),
|
| 56 |
+
Descriptors.NumHDonors(mol),
|
| 57 |
+
Descriptors.NumHAcceptors(mol)
|
| 58 |
+
], dtype=np.float32)
|
| 59 |
+
feat = np.concatenate([fp, desc])
|
| 60 |
+
else:
|
| 61 |
+
feat = fp
|
| 62 |
+
|
| 63 |
+
X_list.append(feat)
|
| 64 |
+
|
| 65 |
+
if hasattr(data, 'y') and data.y is not None:
|
| 66 |
+
y_val = data.y.cpu().numpy()
|
| 67 |
+
if y_val.ndim == 1:
|
| 68 |
+
y_val = y_val.reshape(1, -1)
|
| 69 |
+
y_list.append(y_val.reshape(-1))
|
| 70 |
+
|
| 71 |
+
X = np.stack(X_list, axis=0) if len(X_list) > 0 else np.empty((0, n_bits))
|
| 72 |
+
y = np.stack(y_list, axis=0) if len(y_list) > 0 else np.empty((0, 1))
|
| 73 |
+
|
| 74 |
+
return X, y
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class ClassicalFingerprintBaseline:
|
| 78 |
+
"""
|
| 79 |
+
Classical Machine Learning Baseline supporting Regression, Binary Classification,
|
| 80 |
+
and Multi-task Masked Classification.
|
| 81 |
+
"""
|
| 82 |
+
def __init__(
|
| 83 |
+
self,
|
| 84 |
+
task_type: str = 'regression',
|
| 85 |
+
num_tasks: int = 1,
|
| 86 |
+
model_type: str = 'random_forest',
|
| 87 |
+
n_estimators: int = 100,
|
| 88 |
+
random_state: int = 42,
|
| 89 |
+
n_bits: int = 2048,
|
| 90 |
+
use_descriptors: bool = True
|
| 91 |
+
):
|
| 92 |
+
self.task_type = task_type
|
| 93 |
+
self.num_tasks = num_tasks
|
| 94 |
+
self.model_type = model_type
|
| 95 |
+
self.n_estimators = n_estimators
|
| 96 |
+
self.random_state = random_state
|
| 97 |
+
self.n_bits = n_bits
|
| 98 |
+
self.use_descriptors = use_descriptors
|
| 99 |
+
self.models: List[Any] = []
|
| 100 |
+
self.is_fitted = False
|
| 101 |
+
|
| 102 |
+
def _create_single_model(self):
|
| 103 |
+
if self.task_type == 'regression':
|
| 104 |
+
if self.model_type == 'gradient_boosting':
|
| 105 |
+
return GradientBoostingRegressor(n_estimators=self.n_estimators, random_state=self.random_state)
|
| 106 |
+
return RandomForestRegressor(n_estimators=self.n_estimators, random_state=self.random_state, n_jobs=-1)
|
| 107 |
+
else:
|
| 108 |
+
# Classification
|
| 109 |
+
return RandomForestClassifier(n_estimators=self.n_estimators, random_state=self.random_state, n_jobs=-1)
|
| 110 |
+
|
| 111 |
+
def fit(self, train_data: List[Data]) -> 'ClassicalFingerprintBaseline':
|
| 112 |
+
"""Train baseline model(s) on PyG data list."""
|
| 113 |
+
X, y = extract_features(train_data, n_bits=self.n_bits, use_descriptors=self.use_descriptors)
|
| 114 |
+
self.models = []
|
| 115 |
+
|
| 116 |
+
if self.task_type == 'regression':
|
| 117 |
+
for t in range(self.num_tasks):
|
| 118 |
+
y_t = y[:, t]
|
| 119 |
+
valid_mask = ~np.isnan(y_t)
|
| 120 |
+
model = self._create_single_model()
|
| 121 |
+
model.fit(X[valid_mask], y_t[valid_mask])
|
| 122 |
+
self.models.append(model)
|
| 123 |
+
else:
|
| 124 |
+
# Classification: Fit per task to seamlessly handle missing/NaN targets (e.g. Tox21)
|
| 125 |
+
for t in range(self.num_tasks):
|
| 126 |
+
y_t = y[:, t]
|
| 127 |
+
valid_mask = ~np.isnan(y_t)
|
| 128 |
+
model = self._create_single_model()
|
| 129 |
+
# Check if both classes are present
|
| 130 |
+
unique_classes = np.unique(y_t[valid_mask])
|
| 131 |
+
if len(unique_classes) > 1:
|
| 132 |
+
model.fit(X[valid_mask], y_t[valid_mask].astype(int))
|
| 133 |
+
else:
|
| 134 |
+
# Trivial case: single class in split
|
| 135 |
+
model = None
|
| 136 |
+
self.models.append(model)
|
| 137 |
+
|
| 138 |
+
self.is_fitted = True
|
| 139 |
+
return self
|
| 140 |
+
|
| 141 |
+
def predict(self, test_data: List[Data]) -> np.ndarray:
|
| 142 |
+
"""
|
| 143 |
+
Generate predictions for PyG Data objects.
|
| 144 |
+
Returns:
|
| 145 |
+
preds: np.ndarray of shape (num_samples, num_tasks)
|
| 146 |
+
"""
|
| 147 |
+
if not self.is_fitted:
|
| 148 |
+
raise RuntimeError("Model is not fitted yet.")
|
| 149 |
+
|
| 150 |
+
X, _ = extract_features(test_data, n_bits=self.n_bits, use_descriptors=self.use_descriptors)
|
| 151 |
+
preds = np.zeros((len(X), self.num_tasks), dtype=np.float32)
|
| 152 |
+
|
| 153 |
+
for t in range(self.num_tasks):
|
| 154 |
+
model = self.models[t]
|
| 155 |
+
if model is None:
|
| 156 |
+
preds[:, t] = 0.5
|
| 157 |
+
elif self.task_type == 'regression':
|
| 158 |
+
preds[:, t] = model.predict(X)
|
| 159 |
+
else:
|
| 160 |
+
# Return probability of positive class
|
| 161 |
+
prob = model.predict_proba(X)
|
| 162 |
+
if prob.shape[1] > 1:
|
| 163 |
+
preds[:, t] = prob[:, 1]
|
| 164 |
+
else:
|
| 165 |
+
preds[:, t] = prob[:, 0]
|
| 166 |
+
|
| 167 |
+
return preds
|
| 168 |
+
|
| 169 |
+
def evaluate(self, test_data: List[Data]) -> Dict[str, float]:
|
| 170 |
+
"""Evaluate fitted model on test dataset and return metric dictionary."""
|
| 171 |
+
preds = self.predict(test_data)
|
| 172 |
+
_, y_true = extract_features(test_data, n_bits=self.n_bits, use_descriptors=self.use_descriptors)
|
| 173 |
+
|
| 174 |
+
metrics = {}
|
| 175 |
+
if self.task_type == 'regression':
|
| 176 |
+
rmse_list, mae_list, r2_list = [], [], []
|
| 177 |
+
for t in range(self.num_tasks):
|
| 178 |
+
yt = y_true[:, t]
|
| 179 |
+
yp = preds[:, t]
|
| 180 |
+
mask = ~np.isnan(yt)
|
| 181 |
+
if np.sum(mask) > 1:
|
| 182 |
+
rmse_list.append(np.sqrt(mean_squared_error(yt[mask], yp[mask])))
|
| 183 |
+
mae_list.append(mean_absolute_error(yt[mask], yp[mask]))
|
| 184 |
+
r2_list.append(r2_score(yt[mask], yp[mask]))
|
| 185 |
+
|
| 186 |
+
metrics['rmse'] = float(np.mean(rmse_list))
|
| 187 |
+
metrics['mae'] = float(np.mean(mae_list))
|
| 188 |
+
metrics['r2'] = float(np.mean(r2_list))
|
| 189 |
+
else:
|
| 190 |
+
roc_list, pr_list = [], []
|
| 191 |
+
for t in range(self.num_tasks):
|
| 192 |
+
yt = y_true[:, t]
|
| 193 |
+
yp = preds[:, t]
|
| 194 |
+
mask = ~np.isnan(yt)
|
| 195 |
+
# Check that both classes exist
|
| 196 |
+
if len(np.unique(yt[mask])) > 1:
|
| 197 |
+
roc_list.append(roc_auc_score(yt[mask], yp[mask]))
|
| 198 |
+
pr_list.append(average_precision_score(yt[mask], yp[mask]))
|
| 199 |
+
|
| 200 |
+
metrics['roc_auc'] = float(np.mean(roc_list)) if roc_list else 0.5
|
| 201 |
+
metrics['pr_auc'] = float(np.mean(pr_list)) if pr_list else 0.0
|
| 202 |
+
|
| 203 |
+
return metrics
|
| 204 |
+
|
| 205 |
+
def save(self, filepath: str):
|
| 206 |
+
"""Save fitted model to disk."""
|
| 207 |
+
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
| 208 |
+
with open(filepath, 'wb') as f:
|
| 209 |
+
pickle.dump(self, f)
|
| 210 |
+
|
| 211 |
+
@classmethod
|
| 212 |
+
def load(cls, filepath: str) -> 'ClassicalFingerprintBaseline':
|
| 213 |
+
"""Load fitted model from disk."""
|
| 214 |
+
with open(filepath, 'rb') as f:
|
| 215 |
+
return pickle.load(f)
|
src/models/gnn.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Modular Graph Neural Network (GNN) Architectures for Molecular Property Prediction.
|
| 3 |
+
Includes GCN, GAT, GIN, and MPNN (NNConv) with dual global pooling and residual connections.
|
| 4 |
+
"""
|
| 5 |
+
from typing import Optional, List, Tuple
|
| 6 |
+
import torch
|
| 7 |
+
import torch.nn as nn
|
| 8 |
+
import torch.nn.functional as F
|
| 9 |
+
from torch_geometric.nn import (
|
| 10 |
+
GCNConv,
|
| 11 |
+
GATConv,
|
| 12 |
+
GINConv,
|
| 13 |
+
NNConv,
|
| 14 |
+
global_mean_pool,
|
| 15 |
+
global_add_pool
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class BaseGNN(nn.Module):
|
| 20 |
+
"""
|
| 21 |
+
Base class for molecular GNNs.
|
| 22 |
+
Implements multi-layer messaging, batch norm, residual connections,
|
| 23 |
+
dual pooling ([mean_pool || add_pool]), and task prediction head.
|
| 24 |
+
"""
|
| 25 |
+
def __init__(
|
| 26 |
+
self,
|
| 27 |
+
in_channels: int,
|
| 28 |
+
hidden_dim: int = 64,
|
| 29 |
+
num_layers: int = 3,
|
| 30 |
+
num_tasks: int = 1,
|
| 31 |
+
dropout: float = 0.2,
|
| 32 |
+
residual: bool = True
|
| 33 |
+
):
|
| 34 |
+
super().__init__()
|
| 35 |
+
self.in_channels = in_channels
|
| 36 |
+
self.hidden_dim = hidden_dim
|
| 37 |
+
self.num_layers = num_layers
|
| 38 |
+
self.num_tasks = num_tasks
|
| 39 |
+
self.dropout_rate = dropout
|
| 40 |
+
self.residual = residual
|
| 41 |
+
|
| 42 |
+
self.convs = nn.ModuleList()
|
| 43 |
+
self.bns = nn.ModuleList()
|
| 44 |
+
|
| 45 |
+
# Dual pooling produces 2 * hidden_dim features
|
| 46 |
+
self.head = nn.Sequential(
|
| 47 |
+
nn.Linear(hidden_dim * 2, hidden_dim),
|
| 48 |
+
nn.ReLU(),
|
| 49 |
+
nn.Dropout(dropout),
|
| 50 |
+
nn.Linear(hidden_dim, num_tasks)
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
def forward(
|
| 54 |
+
self,
|
| 55 |
+
x: torch.Tensor,
|
| 56 |
+
edge_index: torch.Tensor,
|
| 57 |
+
edge_attr: Optional[torch.Tensor] = None,
|
| 58 |
+
batch: Optional[torch.Tensor] = None
|
| 59 |
+
) -> torch.Tensor:
|
| 60 |
+
"""Forward pass through GNN backbone, dual pooling, and task head."""
|
| 61 |
+
h = self.get_node_representations(x, edge_index, edge_attr)
|
| 62 |
+
|
| 63 |
+
if batch is None:
|
| 64 |
+
batch = torch.zeros(h.size(0), dtype=torch.long, device=h.device)
|
| 65 |
+
|
| 66 |
+
# Dual pooling: concatenation of mean and sum pooling
|
| 67 |
+
mean_p = global_mean_pool(h, batch)
|
| 68 |
+
sum_p = global_add_pool(h, batch)
|
| 69 |
+
pooled = torch.cat([mean_p, sum_p], dim=1)
|
| 70 |
+
|
| 71 |
+
return self.head(pooled)
|
| 72 |
+
|
| 73 |
+
def get_embedding(
|
| 74 |
+
self,
|
| 75 |
+
x: torch.Tensor,
|
| 76 |
+
edge_index: torch.Tensor,
|
| 77 |
+
edge_attr: Optional[torch.Tensor] = None,
|
| 78 |
+
batch: Optional[torch.Tensor] = None
|
| 79 |
+
) -> torch.Tensor:
|
| 80 |
+
"""Extract graph-level latent representations (used for t-SNE / analysis)."""
|
| 81 |
+
h = self.get_node_representations(x, edge_index, edge_attr)
|
| 82 |
+
if batch is None:
|
| 83 |
+
batch = torch.zeros(h.size(0), dtype=torch.long, device=h.device)
|
| 84 |
+
|
| 85 |
+
mean_p = global_mean_pool(h, batch)
|
| 86 |
+
sum_p = global_add_pool(h, batch)
|
| 87 |
+
return torch.cat([mean_p, sum_p], dim=1)
|
| 88 |
+
|
| 89 |
+
def get_node_representations(
|
| 90 |
+
self,
|
| 91 |
+
x: torch.Tensor,
|
| 92 |
+
edge_index: torch.Tensor,
|
| 93 |
+
edge_attr: Optional[torch.Tensor] = None
|
| 94 |
+
) -> torch.Tensor:
|
| 95 |
+
"""Subclasses implement convolution message passing over nodes."""
|
| 96 |
+
raise NotImplementedError
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
class GCN(BaseGNN):
|
| 100 |
+
"""Graph Convolutional Network (Kipf & Welling)."""
|
| 101 |
+
def __init__(
|
| 102 |
+
self,
|
| 103 |
+
in_channels: int,
|
| 104 |
+
hidden_dim: int = 64,
|
| 105 |
+
num_layers: int = 3,
|
| 106 |
+
num_tasks: int = 1,
|
| 107 |
+
dropout: float = 0.2,
|
| 108 |
+
residual: bool = True,
|
| 109 |
+
**kwargs
|
| 110 |
+
):
|
| 111 |
+
super().__init__(in_channels, hidden_dim, num_layers, num_tasks, dropout, residual)
|
| 112 |
+
for i in range(num_layers):
|
| 113 |
+
dim_in = in_channels if i == 0 else hidden_dim
|
| 114 |
+
self.convs.append(GCNConv(dim_in, hidden_dim))
|
| 115 |
+
self.bns.append(nn.BatchNorm1d(hidden_dim))
|
| 116 |
+
|
| 117 |
+
def get_node_representations(
|
| 118 |
+
self,
|
| 119 |
+
x: torch.Tensor,
|
| 120 |
+
edge_index: torch.Tensor,
|
| 121 |
+
edge_attr: Optional[torch.Tensor] = None
|
| 122 |
+
) -> torch.Tensor:
|
| 123 |
+
h = x
|
| 124 |
+
for i in range(self.num_layers):
|
| 125 |
+
h_new = self.convs[i](h, edge_index)
|
| 126 |
+
h_new = self.bns[i](h_new)
|
| 127 |
+
h_new = F.relu(h_new)
|
| 128 |
+
h_new = F.dropout(h_new, p=self.dropout_rate, training=self.training)
|
| 129 |
+
if self.residual and h.shape == h_new.shape:
|
| 130 |
+
h = h + h_new
|
| 131 |
+
else:
|
| 132 |
+
h = h_new
|
| 133 |
+
return h
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
class GAT(BaseGNN):
|
| 137 |
+
"""Graph Attention Network with Multi-Head Attention (Veličković et al.)."""
|
| 138 |
+
def __init__(
|
| 139 |
+
self,
|
| 140 |
+
in_channels: int,
|
| 141 |
+
hidden_dim: int = 64,
|
| 142 |
+
num_layers: int = 3,
|
| 143 |
+
num_tasks: int = 1,
|
| 144 |
+
dropout: float = 0.2,
|
| 145 |
+
residual: bool = True,
|
| 146 |
+
heads: int = 4,
|
| 147 |
+
**kwargs
|
| 148 |
+
):
|
| 149 |
+
super().__init__(in_channels, hidden_dim, num_layers, num_tasks, dropout, residual)
|
| 150 |
+
self.heads = heads
|
| 151 |
+
assert hidden_dim % heads == 0, f"hidden_dim ({hidden_dim}) must be divisible by heads ({heads})"
|
| 152 |
+
head_dim = hidden_dim // heads
|
| 153 |
+
|
| 154 |
+
for i in range(num_layers):
|
| 155 |
+
dim_in = in_channels if i == 0 else hidden_dim
|
| 156 |
+
self.convs.append(GATConv(dim_in, head_dim, heads=heads, concat=True, dropout=dropout))
|
| 157 |
+
self.bns.append(nn.BatchNorm1d(hidden_dim))
|
| 158 |
+
|
| 159 |
+
def get_node_representations(
|
| 160 |
+
self,
|
| 161 |
+
x: torch.Tensor,
|
| 162 |
+
edge_index: torch.Tensor,
|
| 163 |
+
edge_attr: Optional[torch.Tensor] = None
|
| 164 |
+
) -> torch.Tensor:
|
| 165 |
+
h = x
|
| 166 |
+
for i in range(self.num_layers):
|
| 167 |
+
h_new = self.convs[i](h, edge_index)
|
| 168 |
+
h_new = self.bns[i](h_new)
|
| 169 |
+
h_new = F.elu(h_new)
|
| 170 |
+
h_new = F.dropout(h_new, p=self.dropout_rate, training=self.training)
|
| 171 |
+
if self.residual and h.shape == h_new.shape:
|
| 172 |
+
h = h + h_new
|
| 173 |
+
else:
|
| 174 |
+
h = h_new
|
| 175 |
+
return h
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
class GIN(BaseGNN):
|
| 179 |
+
"""Graph Isomorphism Network with MLP aggregators (Xu et al.)."""
|
| 180 |
+
def __init__(
|
| 181 |
+
self,
|
| 182 |
+
in_channels: int,
|
| 183 |
+
hidden_dim: int = 64,
|
| 184 |
+
num_layers: int = 3,
|
| 185 |
+
num_tasks: int = 1,
|
| 186 |
+
dropout: float = 0.2,
|
| 187 |
+
residual: bool = True,
|
| 188 |
+
train_eps: bool = True,
|
| 189 |
+
**kwargs
|
| 190 |
+
):
|
| 191 |
+
super().__init__(in_channels, hidden_dim, num_layers, num_tasks, dropout, residual)
|
| 192 |
+
for i in range(num_layers):
|
| 193 |
+
dim_in = in_channels if i == 0 else hidden_dim
|
| 194 |
+
mlp = nn.Sequential(
|
| 195 |
+
nn.Linear(dim_in, hidden_dim),
|
| 196 |
+
nn.BatchNorm1d(hidden_dim),
|
| 197 |
+
nn.ReLU(),
|
| 198 |
+
nn.Linear(hidden_dim, hidden_dim)
|
| 199 |
+
)
|
| 200 |
+
self.convs.append(GINConv(mlp, train_eps=train_eps))
|
| 201 |
+
self.bns.append(nn.BatchNorm1d(hidden_dim))
|
| 202 |
+
|
| 203 |
+
def get_node_representations(
|
| 204 |
+
self,
|
| 205 |
+
x: torch.Tensor,
|
| 206 |
+
edge_index: torch.Tensor,
|
| 207 |
+
edge_attr: Optional[torch.Tensor] = None
|
| 208 |
+
) -> torch.Tensor:
|
| 209 |
+
h = x
|
| 210 |
+
for i in range(self.num_layers):
|
| 211 |
+
h_new = self.convs[i](h, edge_index)
|
| 212 |
+
h_new = self.bns[i](h_new)
|
| 213 |
+
h_new = F.relu(h_new)
|
| 214 |
+
h_new = F.dropout(h_new, p=self.dropout_rate, training=self.training)
|
| 215 |
+
if self.residual and h.shape == h_new.shape:
|
| 216 |
+
h = h + h_new
|
| 217 |
+
else:
|
| 218 |
+
h = h_new
|
| 219 |
+
return h
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
class MPNN(BaseGNN):
|
| 223 |
+
"""
|
| 224 |
+
Message Passing Neural Network using Edge Attributes (Gilmer et al.).
|
| 225 |
+
Utilizes NNConv where message passing weights are conditioned on edge features.
|
| 226 |
+
"""
|
| 227 |
+
def __init__(
|
| 228 |
+
self,
|
| 229 |
+
in_channels: int,
|
| 230 |
+
edge_dim: int,
|
| 231 |
+
hidden_dim: int = 64,
|
| 232 |
+
num_layers: int = 3,
|
| 233 |
+
num_tasks: int = 1,
|
| 234 |
+
dropout: float = 0.2,
|
| 235 |
+
residual: bool = True,
|
| 236 |
+
**kwargs
|
| 237 |
+
):
|
| 238 |
+
super().__init__(in_channels, hidden_dim, num_layers, num_tasks, dropout, residual)
|
| 239 |
+
self.edge_dim = edge_dim
|
| 240 |
+
self.edge_nns = nn.ModuleList()
|
| 241 |
+
|
| 242 |
+
for i in range(num_layers):
|
| 243 |
+
dim_in = in_channels if i == 0 else hidden_dim
|
| 244 |
+
edge_nn = nn.Sequential(
|
| 245 |
+
nn.Linear(edge_dim, 32),
|
| 246 |
+
nn.ReLU(),
|
| 247 |
+
nn.Linear(32, dim_in * hidden_dim)
|
| 248 |
+
)
|
| 249 |
+
self.edge_nns.append(edge_nn)
|
| 250 |
+
self.convs.append(NNConv(dim_in, hidden_dim, edge_nn, aggr='mean'))
|
| 251 |
+
self.bns.append(nn.BatchNorm1d(hidden_dim))
|
| 252 |
+
|
| 253 |
+
def get_node_representations(
|
| 254 |
+
self,
|
| 255 |
+
x: torch.Tensor,
|
| 256 |
+
edge_index: torch.Tensor,
|
| 257 |
+
edge_attr: Optional[torch.Tensor] = None
|
| 258 |
+
) -> torch.Tensor:
|
| 259 |
+
# Fallback for empty edges or missing edge attributes
|
| 260 |
+
if edge_attr is None or edge_attr.size(0) == 0:
|
| 261 |
+
edge_attr = torch.zeros((edge_index.size(1), self.edge_dim), dtype=torch.float, device=x.device)
|
| 262 |
+
|
| 263 |
+
h = x
|
| 264 |
+
for i in range(self.num_layers):
|
| 265 |
+
h_new = self.convs[i](h, edge_index, edge_attr)
|
| 266 |
+
h_new = self.bns[i](h_new)
|
| 267 |
+
h_new = F.relu(h_new)
|
| 268 |
+
h_new = F.dropout(h_new, p=self.dropout_rate, training=self.training)
|
| 269 |
+
if self.residual and h.shape == h_new.shape:
|
| 270 |
+
h = h + h_new
|
| 271 |
+
else:
|
| 272 |
+
h = h_new
|
| 273 |
+
return h
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def build_gnn_model(
|
| 277 |
+
model_name: str,
|
| 278 |
+
in_channels: int,
|
| 279 |
+
edge_dim: int = 10,
|
| 280 |
+
num_tasks: int = 1,
|
| 281 |
+
hidden_dim: int = 64,
|
| 282 |
+
num_layers: int = 3,
|
| 283 |
+
dropout: float = 0.2,
|
| 284 |
+
residual: bool = True,
|
| 285 |
+
**kwargs
|
| 286 |
+
) -> BaseGNN:
|
| 287 |
+
"""Factory function for creating GNN models."""
|
| 288 |
+
name = model_name.lower()
|
| 289 |
+
if name == 'gcn':
|
| 290 |
+
return GCN(in_channels, hidden_dim, num_layers, num_tasks, dropout, residual, **kwargs)
|
| 291 |
+
elif name == 'gat':
|
| 292 |
+
return GAT(in_channels, hidden_dim, num_layers, num_tasks, dropout, residual, **kwargs)
|
| 293 |
+
elif name == 'gin':
|
| 294 |
+
return GIN(in_channels, hidden_dim, num_layers, num_tasks, dropout, residual, **kwargs)
|
| 295 |
+
elif name == 'mpnn':
|
| 296 |
+
return MPNN(in_channels, edge_dim, hidden_dim, num_layers, num_tasks, dropout, residual, **kwargs)
|
| 297 |
+
else:
|
| 298 |
+
raise ValueError(f"Unknown GNN architecture '{model_name}'. Choose from: gcn, gat, gin, mpnn")
|
src/train.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Training and Validation Pipeline for GNNs and Classical Baselines.
|
| 3 |
+
Includes CPU thread limits, masked loss, early stopping, LR scheduling,
|
| 4 |
+
metrics computation, and multi-seed benchmarking.
|
| 5 |
+
"""
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
|
| 9 |
+
# Ensure project root is in sys.path
|
| 10 |
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
| 11 |
+
|
| 12 |
+
import time
|
| 13 |
+
import json
|
| 14 |
+
import random
|
| 15 |
+
import logging
|
| 16 |
+
import argparse
|
| 17 |
+
from typing import Dict, List, Tuple, Any, Optional
|
| 18 |
+
|
| 19 |
+
import yaml
|
| 20 |
+
import numpy as np
|
| 21 |
+
import torch
|
| 22 |
+
import torch.nn as nn
|
| 23 |
+
import torch.nn.functional as F
|
| 24 |
+
from torch.optim import Adam
|
| 25 |
+
from torch.optim.lr_scheduler import ReduceLROnPlateau
|
| 26 |
+
from torch_geometric.loader import DataLoader
|
| 27 |
+
from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score, roc_auc_score, average_precision_score
|
| 28 |
+
|
| 29 |
+
from src.data import get_dataloaders, NODE_DIM, EDGE_DIM
|
| 30 |
+
from src.models.gnn import build_gnn_model, BaseGNN
|
| 31 |
+
from src.models.baseline import ClassicalFingerprintBaseline
|
| 32 |
+
|
| 33 |
+
# Enforce 4 CPU threads matching hardware physical cores
|
| 34 |
+
torch.set_num_threads(4)
|
| 35 |
+
|
| 36 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
| 37 |
+
logger = logging.getLogger(__name__)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def set_seed(seed: int = 42):
|
| 41 |
+
"""Set random seed for reproducibility."""
|
| 42 |
+
random.seed(seed)
|
| 43 |
+
np.random.seed(seed)
|
| 44 |
+
torch.manual_seed(seed)
|
| 45 |
+
if torch.cuda.is_available():
|
| 46 |
+
torch.cuda.manual_seed_all(seed)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def compute_loss(preds: torch.Tensor, targets: torch.Tensor, task_type: str) -> torch.Tensor:
|
| 50 |
+
"""Compute task loss with NaN masking for missing multi-task targets."""
|
| 51 |
+
mask = ~torch.isnan(targets)
|
| 52 |
+
if mask.sum() == 0:
|
| 53 |
+
return torch.tensor(0.0, requires_grad=True, device=preds.device)
|
| 54 |
+
|
| 55 |
+
if task_type == 'regression':
|
| 56 |
+
return F.mse_loss(preds[mask], targets[mask])
|
| 57 |
+
else:
|
| 58 |
+
# Binary or multi-task classification
|
| 59 |
+
return F.binary_cross_entropy_with_logits(preds[mask], targets[mask])
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def evaluate_gnn(
|
| 63 |
+
model: nn.Module,
|
| 64 |
+
loader: DataLoader,
|
| 65 |
+
task_type: str,
|
| 66 |
+
device: torch.device
|
| 67 |
+
) -> Tuple[Dict[str, float], np.ndarray, np.ndarray]:
|
| 68 |
+
"""
|
| 69 |
+
Evaluate GNN model on a DataLoader.
|
| 70 |
+
Returns:
|
| 71 |
+
metrics: dictionary of evaluation metrics
|
| 72 |
+
all_preds: numpy array of predictions
|
| 73 |
+
all_targets: numpy array of targets
|
| 74 |
+
"""
|
| 75 |
+
model.eval()
|
| 76 |
+
preds_list = []
|
| 77 |
+
targets_list = []
|
| 78 |
+
|
| 79 |
+
with torch.no_grad():
|
| 80 |
+
for batch in loader:
|
| 81 |
+
batch = batch.to(device)
|
| 82 |
+
out = model(batch.x, batch.edge_index, edge_attr=batch.edge_attr, batch=batch.batch)
|
| 83 |
+
if task_type != 'regression':
|
| 84 |
+
out = torch.sigmoid(out)
|
| 85 |
+
preds_list.append(out.cpu().numpy())
|
| 86 |
+
targets_list.append(batch.y.cpu().numpy())
|
| 87 |
+
|
| 88 |
+
preds = np.concatenate(preds_list, axis=0) if preds_list else np.empty((0, 1))
|
| 89 |
+
targets = np.concatenate(targets_list, axis=0) if targets_list else np.empty((0, 1))
|
| 90 |
+
|
| 91 |
+
num_tasks = targets.shape[1] if targets.ndim > 1 else 1
|
| 92 |
+
metrics: Dict[str, float] = {}
|
| 93 |
+
|
| 94 |
+
if task_type == 'regression':
|
| 95 |
+
rmse_list, mae_list, r2_list = [], [], []
|
| 96 |
+
for t in range(num_tasks):
|
| 97 |
+
yt = targets[:, t]
|
| 98 |
+
yp = preds[:, t]
|
| 99 |
+
mask = ~np.isnan(yt)
|
| 100 |
+
if np.sum(mask) > 1:
|
| 101 |
+
rmse_list.append(np.sqrt(mean_squared_error(yt[mask], yp[mask])))
|
| 102 |
+
mae_list.append(mean_absolute_error(yt[mask], yp[mask]))
|
| 103 |
+
r2_list.append(r2_score(yt[mask], yp[mask]))
|
| 104 |
+
|
| 105 |
+
metrics['rmse'] = float(np.mean(rmse_list)) if rmse_list else 0.0
|
| 106 |
+
metrics['mae'] = float(np.mean(mae_list)) if mae_list else 0.0
|
| 107 |
+
metrics['r2'] = float(np.mean(r2_list)) if r2_list else 0.0
|
| 108 |
+
else:
|
| 109 |
+
roc_list, pr_list = [], []
|
| 110 |
+
for t in range(num_tasks):
|
| 111 |
+
yt = targets[:, t]
|
| 112 |
+
yp = preds[:, t]
|
| 113 |
+
mask = ~np.isnan(yt)
|
| 114 |
+
if len(np.unique(yt[mask])) > 1:
|
| 115 |
+
roc_list.append(roc_auc_score(yt[mask], yp[mask]))
|
| 116 |
+
pr_list.append(average_precision_score(yt[mask], yp[mask]))
|
| 117 |
+
|
| 118 |
+
metrics['roc_auc'] = float(np.mean(roc_list)) if roc_list else 0.5
|
| 119 |
+
metrics['pr_auc'] = float(np.mean(pr_list)) if pr_list else 0.0
|
| 120 |
+
|
| 121 |
+
return metrics, preds, targets
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def train_single_seed(
|
| 125 |
+
model_name: str,
|
| 126 |
+
config: Dict[str, Any],
|
| 127 |
+
seed: int = 42,
|
| 128 |
+
quick: bool = False,
|
| 129 |
+
override_epochs: Optional[int] = None,
|
| 130 |
+
save_dir: str = 'results/checkpoints'
|
| 131 |
+
) -> Dict[str, Any]:
|
| 132 |
+
"""Train a single model architecture on a specific random seed."""
|
| 133 |
+
set_seed(seed)
|
| 134 |
+
device = torch.device('cpu')
|
| 135 |
+
os.makedirs(save_dir, exist_ok=True)
|
| 136 |
+
os.makedirs('results/history', exist_ok=True)
|
| 137 |
+
|
| 138 |
+
dataset_name = config['dataset']
|
| 139 |
+
task_type = config['task_type']
|
| 140 |
+
num_tasks = config['num_tasks']
|
| 141 |
+
primary_metric = config['metric']
|
| 142 |
+
higher_is_better = config.get('higher_is_better', False)
|
| 143 |
+
|
| 144 |
+
train_cfg = config.get('training', {})
|
| 145 |
+
model_cfg = config.get('model', {})
|
| 146 |
+
|
| 147 |
+
batch_size = train_cfg.get('batch_size', 32)
|
| 148 |
+
max_epochs = override_epochs or (2 if quick else train_cfg.get('epochs', 50))
|
| 149 |
+
lr = train_cfg.get('lr', 0.001)
|
| 150 |
+
weight_decay = train_cfg.get('weight_decay', 1e-4)
|
| 151 |
+
patience = train_cfg.get('patience', 15)
|
| 152 |
+
grad_clip = train_cfg.get('grad_clip', 1.0)
|
| 153 |
+
|
| 154 |
+
# 1. Classical Baseline Branch
|
| 155 |
+
if model_name.lower() == 'baseline':
|
| 156 |
+
logger.info(f"Training Classical Baseline (Random Forest) on {dataset_name.upper()} (Seed={seed})...")
|
| 157 |
+
_, _, _, meta = get_dataloaders(dataset_name, batch_size=batch_size, quick=quick)
|
| 158 |
+
baseline = ClassicalFingerprintBaseline(
|
| 159 |
+
task_type=task_type,
|
| 160 |
+
num_tasks=num_tasks,
|
| 161 |
+
n_estimators=20 if quick else 100,
|
| 162 |
+
random_state=seed
|
| 163 |
+
)
|
| 164 |
+
t0 = time.time()
|
| 165 |
+
baseline.fit(meta['train'])
|
| 166 |
+
train_time = time.time() - t0
|
| 167 |
+
|
| 168 |
+
val_metrics = baseline.evaluate(meta['val'])
|
| 169 |
+
test_metrics = baseline.evaluate(meta['test'])
|
| 170 |
+
|
| 171 |
+
ckpt_path = os.path.join(save_dir, f"{dataset_name}_baseline_best.pkl")
|
| 172 |
+
baseline.save(ckpt_path)
|
| 173 |
+
|
| 174 |
+
result = {
|
| 175 |
+
'model_name': 'baseline',
|
| 176 |
+
'dataset_name': dataset_name,
|
| 177 |
+
'seed': seed,
|
| 178 |
+
'train_time': train_time,
|
| 179 |
+
'val_metrics': val_metrics,
|
| 180 |
+
'test_metrics': test_metrics,
|
| 181 |
+
'checkpoint_path': ckpt_path
|
| 182 |
+
}
|
| 183 |
+
return result
|
| 184 |
+
|
| 185 |
+
# 2. GNN Training Branch
|
| 186 |
+
train_loader, val_loader, test_loader, meta = get_dataloaders(dataset_name, batch_size=batch_size, quick=quick)
|
| 187 |
+
|
| 188 |
+
model = build_gnn_model(
|
| 189 |
+
model_name=model_name,
|
| 190 |
+
in_channels=NODE_DIM,
|
| 191 |
+
edge_dim=EDGE_DIM,
|
| 192 |
+
num_tasks=num_tasks,
|
| 193 |
+
hidden_dim=model_cfg.get('hidden_dim', 64),
|
| 194 |
+
num_layers=model_cfg.get('num_layers', 3),
|
| 195 |
+
dropout=model_cfg.get('dropout', 0.2),
|
| 196 |
+
residual=model_cfg.get('residual', True)
|
| 197 |
+
).to(device)
|
| 198 |
+
|
| 199 |
+
optimizer = Adam(model.parameters(), lr=lr, weight_decay=weight_decay)
|
| 200 |
+
scheduler = ReduceLROnPlateau(
|
| 201 |
+
optimizer,
|
| 202 |
+
mode='max' if higher_is_better else 'min',
|
| 203 |
+
factor=train_cfg.get('lr_factor', 0.5),
|
| 204 |
+
patience=train_cfg.get('lr_patience', 5),
|
| 205 |
+
min_lr=1e-5
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
best_val_score = -float('inf') if higher_is_better else float('inf')
|
| 209 |
+
best_epoch = 0
|
| 210 |
+
patience_counter = 0
|
| 211 |
+
best_model_state = None
|
| 212 |
+
history = {'train_loss': [], 'val_metric': [], 'epoch_times': []}
|
| 213 |
+
|
| 214 |
+
t0 = time.time()
|
| 215 |
+
for epoch in range(1, max_epochs + 1):
|
| 216 |
+
ep_t0 = time.time()
|
| 217 |
+
model.train()
|
| 218 |
+
total_loss = 0.0
|
| 219 |
+
num_batches = 0
|
| 220 |
+
|
| 221 |
+
for batch in train_loader:
|
| 222 |
+
batch = batch.to(device)
|
| 223 |
+
optimizer.zero_grad()
|
| 224 |
+
out = model(batch.x, batch.edge_index, edge_attr=batch.edge_attr, batch=batch.batch)
|
| 225 |
+
loss = compute_loss(out, batch.y, task_type)
|
| 226 |
+
loss.backward()
|
| 227 |
+
|
| 228 |
+
if grad_clip > 0:
|
| 229 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=grad_clip)
|
| 230 |
+
|
| 231 |
+
optimizer.step()
|
| 232 |
+
total_loss += loss.item()
|
| 233 |
+
num_batches += 1
|
| 234 |
+
|
| 235 |
+
avg_loss = total_loss / max(num_batches, 1)
|
| 236 |
+
val_metrics, _, _ = evaluate_gnn(model, val_loader, task_type, device)
|
| 237 |
+
val_score = val_metrics[primary_metric]
|
| 238 |
+
|
| 239 |
+
scheduler.step(val_score)
|
| 240 |
+
ep_time = time.time() - ep_t0
|
| 241 |
+
|
| 242 |
+
history['train_loss'].append(avg_loss)
|
| 243 |
+
history['val_metric'].append(val_score)
|
| 244 |
+
history['epoch_times'].append(ep_time)
|
| 245 |
+
|
| 246 |
+
# Check improvement
|
| 247 |
+
is_improved = (val_score > best_val_score) if higher_is_better else (val_score < best_val_score)
|
| 248 |
+
if is_improved:
|
| 249 |
+
best_val_score = val_score
|
| 250 |
+
best_epoch = epoch
|
| 251 |
+
patience_counter = 0
|
| 252 |
+
best_model_state = {k: v.cpu().clone() for k, v in model.state_dict().items()}
|
| 253 |
+
else:
|
| 254 |
+
patience_counter += 1
|
| 255 |
+
|
| 256 |
+
if epoch % 5 == 0 or epoch == 1 or epoch == max_epochs:
|
| 257 |
+
logger.info(f"[{model_name.upper()}|Epoch {epoch:02d}/{max_epochs:02d}] Loss: {avg_loss:.4f} | Val {primary_metric.upper()}: {val_score:.4f} (Best: {best_val_score:.4f}) | Time: {ep_time:.2f}s")
|
| 258 |
+
|
| 259 |
+
if patience_counter >= patience and not quick:
|
| 260 |
+
logger.info(f"Early stopping triggered at epoch {epoch}. Best epoch was {best_epoch}.")
|
| 261 |
+
break
|
| 262 |
+
|
| 263 |
+
total_train_time = time.time() - t0
|
| 264 |
+
|
| 265 |
+
# Load best model for test evaluation
|
| 266 |
+
if best_model_state is not None:
|
| 267 |
+
model.load_state_dict(best_model_state)
|
| 268 |
+
|
| 269 |
+
val_metrics, _, _ = evaluate_gnn(model, val_loader, task_type, device)
|
| 270 |
+
test_metrics, _, _ = evaluate_gnn(model, test_loader, task_type, device)
|
| 271 |
+
|
| 272 |
+
# Save checkpoint
|
| 273 |
+
best_ckpt_path = os.path.join(save_dir, f"{dataset_name}_{model_name.lower()}_best.pt")
|
| 274 |
+
ckpt_payload = {
|
| 275 |
+
'model_name': model_name.lower(),
|
| 276 |
+
'dataset_name': dataset_name,
|
| 277 |
+
'model_state_dict': model.state_dict(),
|
| 278 |
+
'config': config,
|
| 279 |
+
'val_metrics': val_metrics,
|
| 280 |
+
'test_metrics': test_metrics,
|
| 281 |
+
'node_dim': NODE_DIM,
|
| 282 |
+
'edge_dim': EDGE_DIM,
|
| 283 |
+
'num_tasks': num_tasks,
|
| 284 |
+
'model_kwargs': {
|
| 285 |
+
'in_channels': NODE_DIM,
|
| 286 |
+
'edge_dim': EDGE_DIM,
|
| 287 |
+
'num_tasks': num_tasks,
|
| 288 |
+
'hidden_dim': model_cfg.get('hidden_dim', 64),
|
| 289 |
+
'num_layers': model_cfg.get('num_layers', 3),
|
| 290 |
+
'dropout': model_cfg.get('dropout', 0.2),
|
| 291 |
+
'residual': model_cfg.get('residual', True)
|
| 292 |
+
}
|
| 293 |
+
}
|
| 294 |
+
torch.save(ckpt_payload, best_ckpt_path)
|
| 295 |
+
logger.info(f"Saved best checkpoint to {best_ckpt_path} (Test {primary_metric.upper()}: {test_metrics[primary_metric]:.4f})")
|
| 296 |
+
|
| 297 |
+
# Save history
|
| 298 |
+
history_path = f"results/history/{dataset_name}_{model_name.lower()}_history.json"
|
| 299 |
+
with open(history_path, 'w') as f:
|
| 300 |
+
json.dump(history, f)
|
| 301 |
+
|
| 302 |
+
return {
|
| 303 |
+
'model_name': model_name.lower(),
|
| 304 |
+
'dataset_name': dataset_name,
|
| 305 |
+
'seed': seed,
|
| 306 |
+
'train_time': total_train_time,
|
| 307 |
+
'best_epoch': best_epoch,
|
| 308 |
+
'val_metrics': val_metrics,
|
| 309 |
+
'test_metrics': test_metrics,
|
| 310 |
+
'checkpoint_path': best_ckpt_path
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def train_dataset(
|
| 315 |
+
dataset_name: str,
|
| 316 |
+
models: List[str],
|
| 317 |
+
config_path: Optional[str] = None,
|
| 318 |
+
seeds: Optional[List[int]] = None,
|
| 319 |
+
quick: bool = False,
|
| 320 |
+
override_epochs: Optional[int] = None
|
| 321 |
+
) -> Dict[str, Any]:
|
| 322 |
+
"""Train multiple models on a given dataset across seeds, aggregating mean and std."""
|
| 323 |
+
if config_path is None:
|
| 324 |
+
config_path = f"configs/{dataset_name.lower()}.yaml"
|
| 325 |
+
|
| 326 |
+
with open(config_path, 'r') as f:
|
| 327 |
+
config = yaml.safe_load(f)
|
| 328 |
+
|
| 329 |
+
active_seeds = seeds or ([42] if quick else config.get('training', {}).get('seeds', [42, 123, 999]))
|
| 330 |
+
results_summary: Dict[str, Any] = {}
|
| 331 |
+
|
| 332 |
+
for model_name in models:
|
| 333 |
+
seed_results = []
|
| 334 |
+
for seed in active_seeds:
|
| 335 |
+
logger.info(f"\n==================================================")
|
| 336 |
+
logger.info(f"Training {model_name.upper()} on {dataset_name.upper()} | Seed {seed}")
|
| 337 |
+
logger.info(f"==================================================")
|
| 338 |
+
res = train_single_seed(
|
| 339 |
+
model_name=model_name,
|
| 340 |
+
config=config,
|
| 341 |
+
seed=seed,
|
| 342 |
+
quick=quick,
|
| 343 |
+
override_epochs=override_epochs
|
| 344 |
+
)
|
| 345 |
+
seed_results.append(res)
|
| 346 |
+
|
| 347 |
+
# Aggregate metrics across seeds
|
| 348 |
+
metric_keys = seed_results[0]['test_metrics'].keys()
|
| 349 |
+
agg_metrics = {}
|
| 350 |
+
for k in metric_keys:
|
| 351 |
+
vals = [r['test_metrics'][k] for r in seed_results]
|
| 352 |
+
agg_metrics[k] = {
|
| 353 |
+
'mean': float(np.mean(vals)),
|
| 354 |
+
'std': float(np.std(vals)),
|
| 355 |
+
'values': vals
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
avg_time = float(np.mean([r['train_time'] for r in seed_results]))
|
| 359 |
+
|
| 360 |
+
results_summary[model_name] = {
|
| 361 |
+
'aggregated_test_metrics': agg_metrics,
|
| 362 |
+
'mean_train_time_sec': avg_time,
|
| 363 |
+
'runs': seed_results
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
return results_summary
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
if __name__ == "__main__":
|
| 370 |
+
parser = argparse.ArgumentParser(description="Train GNNs and Baselines for Molecular Property Prediction")
|
| 371 |
+
parser.add_argument("--config", type=str, default=None, help="Path to YAML configuration")
|
| 372 |
+
parser.add_argument("--dataset", type=str, default="esol", help="Dataset name: esol, bbbp, tox21")
|
| 373 |
+
parser.add_argument("--model", type=str, default="all", help="Model name: baseline, gcn, gat, gin, mpnn, or all")
|
| 374 |
+
parser.add_argument("--quick", action="store_true", help="Run rapid smoke test (trimmed dataset, 2 epochs)")
|
| 375 |
+
parser.add_argument("--epochs", type=int, default=None, help="Override number of training epochs")
|
| 376 |
+
parser.add_argument("--seeds", type=int, nargs="+", default=None, help="Override seeds, e.g., --seeds 42")
|
| 377 |
+
|
| 378 |
+
args = parser.parse_args()
|
| 379 |
+
|
| 380 |
+
cfg_path = args.config if args.config is not None else f"configs/{args.dataset.lower()}.yaml"
|
| 381 |
+
all_models = ['baseline', 'gcn', 'gat', 'gin', 'mpnn']
|
| 382 |
+
models_to_train = all_models if args.model.lower() == 'all' else [args.model.lower()]
|
| 383 |
+
|
| 384 |
+
summary = train_dataset(
|
| 385 |
+
dataset_name=args.dataset,
|
| 386 |
+
models=models_to_train,
|
| 387 |
+
config_path=cfg_path,
|
| 388 |
+
seeds=args.seeds,
|
| 389 |
+
quick=args.quick,
|
| 390 |
+
override_epochs=args.epochs
|
| 391 |
+
)
|
| 392 |
+
|
| 393 |
+
print("\n" + "="*50)
|
| 394 |
+
print(f"BENCHMARK SUMMARY FOR {args.dataset.upper()}")
|
| 395 |
+
print("="*50)
|
| 396 |
+
for m, d in summary.items():
|
| 397 |
+
metrics_str = ", ".join([f"{k.upper()}: {v['mean']:.4f} ± {v['std']:.4f}" for k, v in d['aggregated_test_metrics'].items()])
|
| 398 |
+
print(f"{m.upper():<10} | {metrics_str} | Avg Time: {d['mean_train_time_sec']:.1f}s")
|