VSRAJHAVEL commited on
Commit
2fd4f2f
·
verified ·
1 Parent(s): 0cef8ed

Upload folder using huggingface_hub

Browse files
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")