AI4deeperScience commited on
Commit
b1ecf21
·
verified ·
1 Parent(s): 1d851c7

Upload app.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. app.py +482 -0
app.py ADDED
@@ -0,0 +1,482 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AntioxFP — GNN-Based Antioxidant Activity Predictor
3
+ ====================================================
4
+ Predicts DPPH radical scavenging activity (pIC50) from SMILES strings
5
+ using a 30-model AttentiveFP ensemble with GNNExplainer atom importance maps.
6
+
7
+ Based on: "Graph Neural Network Models for Predicting the Antioxidant Activity
8
+ of Chemical Compounds" (2025)
9
+ """
10
+
11
+ import os
12
+ import sys
13
+ import warnings
14
+ import io
15
+ import math
16
+
17
+ os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"
18
+ warnings.filterwarnings("ignore")
19
+
20
+ import numpy as np
21
+ import torch
22
+ from torch_geometric.data import Data
23
+ from torch_geometric.explain import Explainer, GNNExplainer
24
+ from rdkit import Chem
25
+ from rdkit.Chem import Descriptors
26
+ from rdkit.Chem.Draw import rdMolDraw2D
27
+ import matplotlib
28
+ matplotlib.use("Agg")
29
+ import matplotlib.pyplot as plt
30
+ import matplotlib.cm as cm
31
+ from matplotlib.colors import Normalize
32
+ from PIL import Image
33
+ import gradio as gr
34
+
35
+ from models_arch import AttentiveFPModel
36
+
37
+ # ── Constants ─────────────────────────────────────────────────────────────────
38
+ MODEL_DIR = os.path.join(os.path.dirname(__file__), "models")
39
+ DEVICE = torch.device("cpu") # HF free CPU tier
40
+
41
+ SEEDS = [42, 1, 100]
42
+ N_FOLDS = 10
43
+ HIDDEN = 200
44
+
45
+ ATOM_TYPES = ["C", "N", "O", "S", "F", "Cl", "Br", "I", "P", "other"]
46
+ DEGREE_VALS = [0, 1, 2, 3, 4, 5]
47
+ CHARGE_VALS = [-2, -1, 0, 1, 2]
48
+ from rdkit.Chem import rdchem
49
+ HYBRID_VALS = [
50
+ rdchem.HybridizationType.SP, rdchem.HybridizationType.SP2,
51
+ rdchem.HybridizationType.SP3, rdchem.HybridizationType.SP3D,
52
+ rdchem.HybridizationType.SP3D2,
53
+ ]
54
+ BOND_TYPES = [
55
+ rdchem.BondType.SINGLE, rdchem.BondType.DOUBLE,
56
+ rdchem.BondType.TRIPLE, rdchem.BondType.AROMATIC,
57
+ ]
58
+
59
+ EXAMPLE_SMILES = [
60
+ ["O=c1c(O)c(-c2ccc(O)c(O)c2)oc2cc(O)cc(O)c12", "Quercetin — high activity flavonol"],
61
+ ["OC(=O)/C=C/c1ccc(O)c(O)c1", "Caffeic acid — phenolic acid"],
62
+ ["Oc1ccc(/C=C/c2cc(O)cc(O)c2)cc1", "Resveratrol — stilbene antioxidant"],
63
+ ["O=c1cc(-c2ccccc2)oc2cc(O)cc(O)c12", "Chrysin — low activity (no B-ring OH)"],
64
+ ["O=c1c(O)c(-c2ccc(O)cc2)oc2cc(O)cc(O)c12", "Kaempferol — moderate activity"],
65
+ ["CC(C)(C)c1cc(C(C)(C)C)cc(CC(=O)Nc2ccccc2)c1","BHA analogue — synthetic antioxidant"],
66
+ ]
67
+
68
+
69
+ # ── Molecular graph builder ────────────────────────────────────────────────────
70
+ def one_hot(val, choices):
71
+ vec = [0] * len(choices)
72
+ idx = choices.index(val) if val in choices else len(choices) - 1
73
+ vec[idx] = 1
74
+ return vec
75
+
76
+
77
+ def atom_feat(atom):
78
+ sym = atom.GetSymbol()
79
+ return (
80
+ one_hot(sym if sym in ATOM_TYPES[:-1] else "other", ATOM_TYPES)
81
+ + one_hot(atom.GetDegree(), DEGREE_VALS)
82
+ + one_hot(atom.GetFormalCharge(), CHARGE_VALS)
83
+ + one_hot(atom.GetHybridization(), HYBRID_VALS)
84
+ + [int(atom.GetIsAromatic())]
85
+ + one_hot(atom.GetTotalNumHs(), [0, 1, 2, 3, 4])
86
+ + [int(atom.IsInRing())]
87
+ + one_hot(atom.GetTotalValence(), [0, 1, 2, 3, 4, 5, 6])
88
+ )
89
+
90
+
91
+ def bond_feat(bond):
92
+ return (
93
+ one_hot(bond.GetBondType(), BOND_TYPES)
94
+ + [int(bond.GetIsConjugated()), int(bond.IsInRing())]
95
+ )
96
+
97
+
98
+ def smiles_to_graph(smiles):
99
+ mol = Chem.MolFromSmiles(smiles)
100
+ if mol is None:
101
+ return None, None
102
+ x = torch.tensor([atom_feat(a) for a in mol.GetAtoms()], dtype=torch.float)
103
+ ei, ea = [], []
104
+ for bond in mol.GetBonds():
105
+ i, j = bond.GetBeginAtomIdx(), bond.GetEndAtomIdx()
106
+ f = bond_feat(bond)
107
+ ei += [[i, j], [j, i]]
108
+ ea += [f, f]
109
+ if not ei:
110
+ return None, None
111
+ graph = Data(
112
+ x=x,
113
+ edge_index=torch.tensor(ei, dtype=torch.long).t().contiguous(),
114
+ edge_attr=torch.tensor(ea, dtype=torch.float),
115
+ batch=torch.zeros(x.size(0), dtype=torch.long),
116
+ )
117
+ return graph, mol
118
+
119
+
120
+ # ── Model loader ──────────────────────────────────────────────────────────────
121
+ _MODELS = None # lazy load
122
+
123
+
124
+ def load_models():
125
+ global _MODELS
126
+ if _MODELS is not None:
127
+ return _MODELS
128
+
129
+ models = []
130
+ for seed in SEEDS:
131
+ seed_tag = "" if seed == 42 else f"_seed{seed}"
132
+ for fold in range(1, N_FOLDS + 1):
133
+ name = f"random_attentivefp{seed_tag}_fold{fold}.pt"
134
+ path = os.path.join(MODEL_DIR, name)
135
+ if not os.path.exists(path):
136
+ continue
137
+ m = AttentiveFPModel(hidden=HIDDEN, num_layers=2,
138
+ num_timesteps=2, dropout=0.2).to(DEVICE)
139
+ m.load_state_dict(torch.load(path, map_location=DEVICE, weights_only=False))
140
+ m.eval()
141
+ models.append(m)
142
+
143
+ if not models:
144
+ # fallback: single canonical model
145
+ path = os.path.join(MODEL_DIR, "random_attentivefp.pt")
146
+ m = AttentiveFPModel(hidden=HIDDEN).to(DEVICE)
147
+ m.load_state_dict(torch.load(path, map_location=DEVICE, weights_only=False))
148
+ m.eval()
149
+ models = [m]
150
+
151
+ _MODELS = models
152
+ return models
153
+
154
+
155
+ # ── Rendering ─────────────────────────────────────────────────────────────────
156
+ def render_atom_importance(mol, atom_weights, size=(600, 450)):
157
+ """Render molecule with per-atom importance heatmap via RDKit Cairo."""
158
+ w = np.array(atom_weights, dtype=float)
159
+ if w.max() > w.min():
160
+ w = (w - w.min()) / (w.max() - w.min())
161
+ else:
162
+ w = np.ones_like(w) * 0.5
163
+
164
+ cmap = cm.get_cmap("RdYlBu_r")
165
+ atom_colors = {i: cmap(float(w[i]))[:3] for i in range(mol.GetNumAtoms())}
166
+ atom_radii = {i: 0.20 + 0.55 * float(w[i]) for i in range(mol.GetNumAtoms())}
167
+ highlight = list(range(mol.GetNumAtoms()))
168
+
169
+ try:
170
+ drawer = rdMolDraw2D.MolDraw2DCairo(*size)
171
+ opts = drawer.drawOptions()
172
+ opts.addAtomIndices = False
173
+ opts.bondLineWidth = 2.0
174
+ rdMolDraw2D.PrepareAndDrawMolecule(
175
+ drawer, mol,
176
+ highlightAtoms=highlight,
177
+ highlightAtomColors=atom_colors,
178
+ highlightAtomRadii=atom_radii,
179
+ highlightBonds=[],
180
+ highlightBondColors={},
181
+ )
182
+ drawer.FinishDrawing()
183
+ img = Image.open(io.BytesIO(drawer.GetDrawingText()))
184
+
185
+ # Add colorbar
186
+ fig, ax = plt.subplots(figsize=(img.width / 100, img.height / 100 + 0.5))
187
+ ax.imshow(img)
188
+ ax.axis("off")
189
+ sm = plt.cm.ScalarMappable(cmap="RdYlBu_r", norm=Normalize(0, 1))
190
+ sm.set_array([])
191
+ cbar = fig.colorbar(sm, ax=ax, orientation="vertical",
192
+ fraction=0.03, pad=0.02, aspect=20)
193
+ cbar.set_label("Atom Importance", fontsize=10)
194
+ cbar.set_ticks([0, 0.5, 1])
195
+ cbar.set_ticklabels(["Low", "Medium", "High"], fontsize=8)
196
+ plt.tight_layout(pad=0.3)
197
+ buf = io.BytesIO()
198
+ plt.savefig(buf, format="png", dpi=120, bbox_inches="tight")
199
+ plt.close()
200
+ buf.seek(0)
201
+ return Image.open(buf).copy()
202
+ except Exception as e:
203
+ print(f"Rendering error: {e}")
204
+ return None
205
+
206
+
207
+ def make_importance_bargraph(mol, atom_weights, pred_pic50):
208
+ """Horizontal bar chart of top-15 atom importances."""
209
+ w = np.array(atom_weights)
210
+ atoms = [mol.GetAtomWithIdx(i).GetSymbol() for i in range(mol.GetNumAtoms())]
211
+ labels = [f"{sym}{i}" for i, sym in enumerate(atoms)]
212
+
213
+ top_k = min(15, mol.GetNumAtoms())
214
+ sort_idx = np.argsort(w)[::-1][:top_k]
215
+ sw = w[sort_idx]
216
+ sl = [labels[i] for i in sort_idx]
217
+
218
+ cmap = cm.get_cmap("RdYlBu_r")
219
+ colors = [cmap(float(v)) for v in sw]
220
+
221
+ fig, ax = plt.subplots(figsize=(6, max(3, top_k * 0.4)))
222
+ ax.barh(range(top_k), sw[::-1], color=colors[::-1])
223
+ ax.set_yticks(range(top_k))
224
+ ax.set_yticklabels(sl[::-1], fontsize=9)
225
+ ax.set_xlabel("Atom Importance Score", fontsize=10)
226
+ ax.set_title(f"Top-{top_k} Atom Importances (pred pIC₅₀ = {pred_pic50:.3f})",
227
+ fontsize=11, fontweight="bold")
228
+ ax.set_xlim(0, 1.05)
229
+ ax.spines["top"].set_visible(False)
230
+ ax.spines["right"].set_visible(False)
231
+ plt.tight_layout()
232
+ buf = io.BytesIO()
233
+ plt.savefig(buf, format="png", dpi=110, bbox_inches="tight")
234
+ plt.close()
235
+ buf.seek(0)
236
+ return Image.open(buf).copy()
237
+
238
+
239
+ # ── Activity interpretation ────────────────────────────────────────────────────
240
+ def interpret_activity(pic50):
241
+ ic50_uM = 10 ** (-pic50) * 1e6
242
+ if pic50 >= 5.0:
243
+ level = "🟢 High"
244
+ desc = "Strong DPPH radical scavenger (IC₅₀ ≤ 10 µM). Comparable to quercetin."
245
+ elif pic50 >= 4.5:
246
+ level = "🟡 Moderate–High"
247
+ desc = "Moderate-to-high radical scavenging activity."
248
+ elif pic50 >= 4.0:
249
+ level = "🟠 Moderate"
250
+ desc = "Moderate DPPH scavenging activity."
251
+ else:
252
+ level = "🔴 Low"
253
+ desc = "Weak DPPH radical scavenger."
254
+ return level, ic50_uM, desc
255
+
256
+
257
+ def pharmacophore_hint(mol, atom_weights):
258
+ """Generate a brief pharmacophore text based on atom weights."""
259
+ w = np.array(atom_weights)
260
+ top_idx = np.argsort(w)[::-1][:5]
261
+ top_atoms = [mol.GetAtomWithIdx(int(i)).GetSymbol() for i in top_idx]
262
+
263
+ # Simple heuristic annotations
264
+ hints = []
265
+ if top_atoms.count("O") >= 2:
266
+ hints.append("**Phenolic hydroxyl / catechol motif** — primary HAT pharmacophore detected")
267
+ if any(mol.GetAtomWithIdx(int(i)).GetIsAromatic() for i in top_idx):
268
+ hints.append("**Aromatic conjugation** — supports radical delocalization (HAT/SET)")
269
+ if any(mol.GetAtomWithIdx(int(i)).GetSymbol() == "C"
270
+ and not mol.GetAtomWithIdx(int(i)).GetIsAromatic() for i in top_idx):
271
+ hints.append("**sp² vinyl/carbonyl carbons** — extended π-system (SET pathway)")
272
+ if not hints:
273
+ hints.append("Importance distributed across scaffold — no single dominant pharmacophore")
274
+
275
+ return "\n".join(f"• {h}" for h in hints)
276
+
277
+
278
+ # ── Core prediction function ───────────────────────────────────────────────────
279
+ def predict(smiles_input, run_explainer):
280
+ smiles = smiles_input.strip()
281
+ if not smiles:
282
+ return (None, None, "⚠️ Please enter a SMILES string.", "", "")
283
+
284
+ graph, mol = smiles_to_graph(smiles)
285
+ if graph is None or mol is None:
286
+ return (None, None,
287
+ "❌ Invalid SMILES string. Please check the input.",
288
+ "", "")
289
+
290
+ models = load_models()
291
+ graph = graph.to(DEVICE)
292
+
293
+ # Ensemble prediction
294
+ preds = []
295
+ with torch.no_grad():
296
+ for m in models:
297
+ out = m(graph.x, graph.edge_index, graph.edge_attr, graph.batch)
298
+ preds.append(out.item())
299
+
300
+ pred_mean = float(np.mean(preds))
301
+ pred_std = float(np.std(preds))
302
+ level, ic50_uM, desc = interpret_activity(pred_mean)
303
+
304
+ result_md = (
305
+ f"## Predicted pIC₅₀: **{pred_mean:.3f} ± {pred_std:.3f}**\n\n"
306
+ f"| Property | Value |\n|---|---|\n"
307
+ f"| Activity level | {level} |\n"
308
+ f"| Estimated IC₅₀ | **{ic50_uM:.1f} µM** |\n"
309
+ f"| Ensemble size | {len(models)} models |\n\n"
310
+ f"*{desc}*\n\n"
311
+ f"> **Note**: pIC₅₀ = −log₁₀(IC₅₀/M). Higher = more active."
312
+ )
313
+
314
+ # Atom importance map
315
+ atom_img = None
316
+ bar_img = None
317
+ pharma_text = ""
318
+
319
+ if run_explainer:
320
+ try:
321
+ explainer = Explainer(
322
+ model=models[0],
323
+ algorithm=GNNExplainer(epochs=150),
324
+ explanation_type="model",
325
+ node_mask_type="attributes",
326
+ edge_mask_type="object",
327
+ model_config=dict(mode="regression", task_level="graph",
328
+ return_type="raw"),
329
+ )
330
+ exp = explainer(
331
+ x=graph.x,
332
+ edge_index=graph.edge_index,
333
+ edge_attr=graph.edge_attr,
334
+ batch=graph.batch,
335
+ )
336
+ raw_w = exp.node_mask.sum(dim=-1).cpu().numpy()
337
+ raw_w = np.abs(raw_w)
338
+ if raw_w.max() > 0:
339
+ raw_w /= raw_w.max()
340
+
341
+ atom_img = render_atom_importance(mol, raw_w)
342
+ bar_img = make_importance_bargraph(mol, raw_w, pred_mean)
343
+ pharma_text = "### Pharmacophore Analysis\n\n" + pharmacophore_hint(mol, raw_w)
344
+ except Exception as e:
345
+ pharma_text = f"⚠️ Explainer error: {e}"
346
+ else:
347
+ pharma_text = (
348
+ "💡 *Enable 'Run GNNExplainer' to generate atom importance maps.*\n\n"
349
+ "The explainer adds ~10–20 s on CPU but reveals which atoms drive the prediction."
350
+ )
351
+
352
+ return atom_img, bar_img, result_md, pharma_text, ""
353
+
354
+
355
+ # ── Gradio UI ─────────────────────────────────────────────────────────────────
356
+ CSS = """
357
+ .main-header {
358
+ background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
359
+ padding: 24px;
360
+ border-radius: 12px;
361
+ margin-bottom: 16px;
362
+ text-align: center;
363
+ color: white;
364
+ }
365
+ .main-header h1 { font-size: 2.2em; margin: 0; font-weight: 800; }
366
+ .main-header p { font-size: 1.05em; opacity: 0.85; margin-top: 8px; }
367
+ .result-box { border: 1px solid #e0e0e0; border-radius: 8px; padding: 16px; }
368
+ .example-btn { font-size: 0.85em !important; }
369
+ footer { display: none !important; }
370
+ """
371
+
372
+ HEADER_HTML = """
373
+ <div class="main-header">
374
+ <h1>🧪 AntioxFP</h1>
375
+ <p>GNN-Based Antioxidant Activity Predictor | AttentiveFP Ensemble × 30 Models</p>
376
+ <p style="font-size:0.85em; opacity:0.65;">
377
+ Predicts DPPH• radical scavenging pIC₅₀ from SMILES · Atom-level interpretability via GNNExplainer
378
+ </p>
379
+ </div>
380
+ """
381
+
382
+ ABOUT_MD = """
383
+ ### About This Tool
384
+
385
+ **AntioxFP** uses a 30-model AttentiveFP ensemble to predict
386
+ DPPH radical scavenging activity (pIC₅₀) for any small molecule
387
+ provided as a SMILES string.
388
+
389
+ **Key features:**
390
+ - 30-model ensemble (3 random seeds × 10-fold CV) → prediction ± uncertainty
391
+ - GNNExplainer atom importance maps → identify key pharmacophores
392
+ - Exceeds descriptor-based benchmark (R² = 0.785 vs 0.78)
393
+ - Applicable domain: ~98% of drug-like antioxidant space
394
+
395
+ **Dataset**: 1,911 DPPH antioxidants (AODB, curated by Ghironi et al. 2025)
396
+
397
+ **Reference**: *Graph Neural Network Models for Predicting the Antioxidant
398
+ Activity of Chemical Compounds*, 2025.
399
+
400
+ **Tips:**
401
+ - pIC₅₀ > 5.0 → strong antioxidant (IC₅₀ ≤ 10 µM)
402
+ - pIC₅₀ 4.0–5.0 → moderate activity
403
+ - pIC₅₀ < 4.0 → weak scavenger
404
+ - Enable GNNExplainer to see which atoms drive the prediction
405
+ """
406
+
407
+
408
+ def predict_wrapper(smiles, run_explainer):
409
+ atom_img, bar_img, result_md, pharma_text, _ = predict(smiles, run_explainer)
410
+ return atom_img, bar_img, result_md, pharma_text
411
+
412
+
413
+ with gr.Blocks(css=CSS, title="AntioxFP — Antioxidant Activity Predictor") as demo:
414
+ gr.HTML(HEADER_HTML)
415
+
416
+ with gr.Row():
417
+ # ── Left panel ────────────────────────────────────────────────────────
418
+ with gr.Column(scale=2):
419
+ smiles_input = gr.Textbox(
420
+ label="SMILES Input",
421
+ placeholder="Enter SMILES string, e.g. O=c1c(O)c(-c2ccc(O)c(O)c2)oc2cc(O)cc(O)c12",
422
+ lines=3,
423
+ max_lines=5,
424
+ )
425
+ with gr.Row():
426
+ run_btn = gr.Button("🔬 Predict Activity", variant="primary", scale=3)
427
+ explainer_cb = gr.Checkbox(
428
+ label="Run GNNExplainer (atom maps, +10–20s)",
429
+ value=True, scale=2,
430
+ )
431
+ clear_btn = gr.Button("🗑️ Clear", variant="secondary")
432
+
433
+ gr.Markdown("**Quick Examples** — click to load:")
434
+ example_rows = []
435
+ for i in range(0, len(EXAMPLE_SMILES), 2):
436
+ with gr.Row():
437
+ for smi, label in EXAMPLE_SMILES[i:i+2]:
438
+ btn = gr.Button(label, elem_classes=["example-btn"])
439
+ btn.click(fn=lambda s=smi: s, outputs=smiles_input)
440
+
441
+ gr.Markdown(ABOUT_MD)
442
+
443
+ # ── Right panel ───────────────────────────────────────────────────────
444
+ with gr.Column(scale=3):
445
+ result_md = gr.Markdown("*Results will appear here after prediction.*",
446
+ elem_classes=["result-box"])
447
+ pharma_text = gr.Markdown("")
448
+
449
+ with gr.Tabs():
450
+ with gr.TabItem("🗺️ Atom Importance Map"):
451
+ atom_img = gr.Image(
452
+ label="2D Atom-Level Importance (blue=low → red=high)",
453
+ type="pil", height=420,
454
+ )
455
+ with gr.TabItem("📊 Top-15 Atom Scores"):
456
+ bar_img = gr.Image(
457
+ label="Atom Importance Bar Chart",
458
+ type="pil", height=420,
459
+ )
460
+
461
+ run_btn.click(
462
+ fn=predict_wrapper,
463
+ inputs=[smiles_input, explainer_cb],
464
+ outputs=[atom_img, bar_img, result_md, pharma_text],
465
+ show_progress="full",
466
+ )
467
+
468
+ clear_btn.click(
469
+ fn=lambda: ("", None, None,
470
+ "*Results will appear here after prediction.*", ""),
471
+ outputs=[smiles_input, atom_img, bar_img, result_md, pharma_text],
472
+ )
473
+
474
+ gr.HTML("""
475
+ <div style="text-align:center;margin-top:16px;opacity:0.5;font-size:0.82em;">
476
+ AntioxFP · AttentiveFP (PyTorch Geometric) · GNNExplainer ·
477
+ RDKit · Built with Gradio · 2025
478
+ </div>
479
+ """)
480
+
481
+ if __name__ == "__main__":
482
+ demo.launch(share=False)