Anamitra-Sarkar
Deploy GNN API service to Hugging Face Space
17e2052
Raw
History Blame Contribute Delete
13.4 kB
import os
import sys
import json
import torch
import numpy as np
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import List, Optional
from huggingface_hub import hf_hub_download
from rdkit import Chem
# Add workspace/src to path to import local modules
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'workspace', 'src')))
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), 'workspace', 'src')))
from model import MultiTaskEvidentialGNN
from dataloader import smiles_to_graph
from explain import explain_molecule, render_molecular_explanation
from scaffold import generate_synthetic_analogs
app = FastAPI(title="Explainable & Uncertainty-Aware Multi-Task GNN API")
# Enable CORS for frontend connection (Vercel)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Global model container
model = None
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
def load_evidential_model():
global model
try:
model = MultiTaskEvidentialGNN(
node_in_dim=31,
edge_in_dim=6,
hidden_dim=64,
num_reg_tasks=2,
num_clf_tasks=2
)
# Try local path first, otherwise download from HF
checkpoint_path = "workspace/models/best_checkpoint.pt"
if not os.path.exists(checkpoint_path):
checkpoint_path = "../workspace/models/best_checkpoint.pt"
if not os.path.exists(checkpoint_path):
print("[Backend] Local checkpoint not found. Downloading from Hugging Face Hub...")
token_val = None
token_path = "/home/anamitra/Downloads/API_Keys_and_Secrets/hf_token"
if os.path.exists(token_path):
with open(token_path, 'r') as f:
token_val = f.read().strip()
try:
# Use HF Hub Download (downloads to HF cache)
checkpoint_path = hf_hub_download(
repo_id="Arko007/multi-task-evidential-gnn",
filename="best_checkpoint.pt",
token=token_val
)
print(f"[Backend] Checkpoint downloaded successfully to: {checkpoint_path}")
except Exception as hf_err:
print(f"[Backend] HF download failed: {hf_err}. Attempting to run uninitialized (fallback only).")
return
# Load weights
model.load_state_dict(torch.load(checkpoint_path, map_location=device))
model.to(device)
model.eval()
print("[Backend] Evidential multi-task model loaded successfully.")
except Exception as e:
print(f"[Backend] ERROR loading GNN model: {e}")
@app.on_event("startup")
def startup_event():
load_evidential_model()
# Resolve Groq Key
if not os.environ.get("GROQ_API_KEY"):
key_path = "/home/anamitra/Downloads/API_Keys_and_Secrets/groq_api.txt"
if os.path.exists(key_path):
with open(key_path, 'r') as f:
os.environ["GROQ_API_KEY"] = f.read().strip()
# Input validation models
class SmilesRequest(BaseModel):
smiles: str
class AnalogRequest(BaseModel):
smiles: str
num_variants: Optional[int] = 30
reaction_smarts: Optional[str] = None
class GroqAuditRequest(BaseModel):
smiles: str
predictions: dict
important_atoms: List[str]
# API Route: Predict
@app.post("/api/predict")
def predict_smiles(req: SmilesRequest):
if model is None:
raise HTTPException(status_code=500, detail="GNN model is not loaded. Please train it first.")
mol = Chem.MolFromSmiles(req.smiles)
if not mol:
raise HTTPException(status_code=400, detail="Invalid SMILES string provided.")
data = smiles_to_graph(req.smiles)
if data is None:
raise HTTPException(status_code=400, detail="Graph extraction failed for SMILES.")
data = data.to(device)
with torch.no_grad():
# Add a batch dimension for single item inference
batch = torch.zeros(data.x.size(0), dtype=torch.long, device=device)
pred_reg, pred_clf = model(data.x, data.edge_index, data.edge_attr, batch)
# Unpack Regressions (0: Caco-2, 1: Solubility)
# pred_reg shape: [1, 2, 4] -> (gamma, nu, alpha, beta)
caco2_gamma, caco2_nu, caco2_alpha, caco2_beta = pred_reg[0, 0].cpu().numpy()
sol_gamma, sol_nu, sol_alpha, sol_beta = pred_reg[0, 1].cpu().numpy()
caco2_aleatoric = float(np.sqrt(caco2_beta / (caco2_alpha - 1.0)))
caco2_epistemic = float(1.0 / np.sqrt(caco2_nu))
sol_aleatoric = float(np.sqrt(sol_beta / (sol_alpha - 1.0)))
sol_epistemic = float(1.0 / np.sqrt(sol_nu))
# Unpack Classifications (0: hERG, 1: AMES)
# pred_clf shape: [1, 2, 2] -> Dirichlet parameters alpha_1, alpha_2
herg_alpha = pred_clf[0, 0].cpu().numpy()
ames_alpha = pred_clf[0, 1].cpu().numpy()
herg_S = float(np.sum(herg_alpha))
herg_prob = float(herg_alpha[1] / herg_S)
herg_epistemic = float(2.0 / herg_S) # u = K / S, K=2
ames_S = float(np.sum(ames_alpha))
ames_prob = float(ames_alpha[1] / ames_S)
ames_epistemic = float(2.0 / ames_S) # u = K / S, K=2
return {
"smiles": req.smiles,
"caco2": float(caco2_gamma),
"caco2_aleatoric": caco2_aleatoric,
"caco2_epistemic": caco2_epistemic,
"solubility": float(sol_gamma),
"solubility_aleatoric": sol_aleatoric,
"solubility_epistemic": sol_epistemic,
"herg": herg_prob,
"herg_epistemic": herg_epistemic,
"ames": ames_prob,
"ames_epistemic": ames_epistemic
}
# API Route: GNNExplainer highlights SVG
@app.post("/api/explain")
def explain_smiles(req: SmilesRequest):
if model is None:
raise HTTPException(status_code=500, detail="GNN model is not loaded.")
mol = Chem.MolFromSmiles(req.smiles)
if not mol:
raise HTTPException(status_code=400, detail="Invalid SMILES.")
try:
data = smiles_to_graph(req.smiles)
data = data.to(device)
# We explain hERG toxicity by default (classification task index 0)
# If the molecule is not hERG toxic, we explain AMES (index 1)
atom_w, edge_w = explain_molecule(model, data, task_type='clf', task_idx=0, epochs=80)
svg_dir = "workspace/explanations"
os.makedirs(svg_dir, exist_ok=True)
temp_svg_path = os.path.join(svg_dir, "temp_explain.svg")
# Render SVG (hERG is toxic/bad endpoint, so is_toxic_or_bad=True)
render_molecular_explanation(req.smiles, atom_w, edge_w, data, temp_svg_path, is_toxic_or_bad=True)
# Read SVG content
with open(temp_svg_path, 'r') as f:
svg_content = f.read()
# Get top important atoms to return symbols
atom_symbols = []
for idx, w in enumerate(atom_w):
atom_symbol = mol.GetAtomWithIdx(idx).GetSymbol()
atom_symbols.append({"index": idx, "symbol": atom_symbol, "weight": float(w)})
# Sort by weight descending
atom_symbols = sorted(atom_symbols, key=lambda x: x["weight"], reverse=True)
return {
"svg": svg_content,
"attributions": atom_symbols[:5] # Top 5 atoms
}
except Exception as e:
import traceback
print(f"[Backend] /api/explain failed: {e}\n{traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"Explanation generation failed: {e}")
# API Route: Generate Structural Analogs
@app.post("/api/generate")
def generate_analogs_route(req: AnalogRequest):
if model is None:
raise HTTPException(status_code=500, detail="GNN model is not loaded.")
analogs = generate_synthetic_analogs(req.smiles, req.reaction_smarts, req.num_variants)
if not analogs:
return {"analogs": []}
results = []
# Predict for each analog
for smiles in analogs:
data = smiles_to_graph(smiles)
if data is None:
continue
data = data.to(device)
with torch.no_grad():
batch = torch.zeros(data.x.size(0), dtype=torch.long, device=device)
pred_reg, pred_clf = model(data.x, data.edge_index, data.edge_attr, batch)
caco2_gamma = float(pred_reg[0, 0, 0].cpu().item())
sol_gamma = float(pred_reg[0, 1, 0].cpu().item())
herg_alpha = pred_clf[0, 0].cpu().numpy()
herg_prob = float(herg_alpha[1] / np.sum(herg_alpha))
ames_alpha = pred_clf[0, 1].cpu().numpy()
ames_prob = float(ames_alpha[1] / np.sum(ames_alpha))
results.append({
"smiles": smiles,
"caco2": caco2_gamma,
"solubility": sol_gamma,
"herg": herg_prob,
"ames": ames_prob,
# Safety score (higher is safer): 1 - max(herg_prob, ames_prob)
"safety_score": 1.0 - max(herg_prob, ames_prob)
})
# Sort by safety score and solubility (higher is better)
results = sorted(results, key=lambda x: (x["safety_score"], x["solubility"]), reverse=True)
return {"analogs": results}
# API Route: Groq Chemical Audit
@app.post("/api/explain_text")
def explain_predictions_groq_route(req: GroqAuditRequest):
groq_key = os.environ.get("GROQ_API_KEY")
if not groq_key:
raise HTTPException(status_code=500, detail="Groq API Key is not set in the server environment.")
try:
from groq import Groq
client = Groq(api_key=groq_key)
prompt = f"""
You are an expert computational toxicologist and medicinal chemist.
A multi-task Graph Neural Network (GNN) trained on Tox21 and ADME datasets has evaluated the following molecule:
SMILES: {req.smiles}
Predictions:
- Cardiac Toxicity (hERG Blockade): {"Toxic (hERG blocker)" if req.predictions['herg'] > 0.5 else "Safe (Non-blocker)"} (Confidence probability: {req.predictions['herg']:.2f}, Epistemic Uncertainty: {req.predictions['herg_epistemic']:.3f})
- Mutagenicity (AMES test): {"Mutagenic" if req.predictions['ames'] > 0.5 else "Non-mutagenic"} (Confidence probability: {req.predictions['ames']:.2f}, Epistemic Uncertainty: {req.predictions['ames_epistemic']:.3f})
- Intestinal Permeability (Caco-2 LogPapp): {req.predictions['caco2']:.3f} cm/s (Epistemic Uncertainty: {req.predictions['caco2_epistemic']:.3f})
- Aqueous Solubility (LogS): {req.predictions['solubility']:.3f} (Epistemic Uncertainty: {req.predictions['solubility_epistemic']:.3f})
GNNExplainer highlighted the following atoms/regions as highly influential for predictions: {req.important_atoms}
Based on these predictions, provide a brief, professional chemical audit report explaining:
1. The biological implications of these ADMET predictions.
2. How the highlighted structural features (e.g. aromatic rings, hydrogen bond donors/acceptors, polar groups) correlate with the predicted toxicity and ADME profiles.
3. Recommendations for modifying the chemical structure to improve solubility or reduce toxicity (e.g. adding specific polar groups, reducing lipophilicity, or using bioisosteres).
Be concise (maximum 3 paragraphs). Use professional medicinal chemistry terms. Do not repeat the prompt.
"""
response = client.chat.completions.create(
model="llama-3.3-70b-versatile",
messages=[{"role": "user", "content": prompt}],
temperature=0.2,
max_tokens=800
)
report = response.choices[0].message.content
return {"report": report}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Groq generation failed: {e}")
# API Route: Diagnostics
@app.get("/api/diagnostics")
def get_diagnostics():
metrics_path = "workspace/models/test_metrics.json"
if not os.path.exists(metrics_path):
metrics_path = "../workspace/models/test_metrics.json"
if os.path.exists(metrics_path):
with open(metrics_path, 'r') as f:
metrics = json.load(f)
return metrics
else:
# Return fallback mock/default metrics if no training has run yet
return {
"reg_Caco-2_mae": 0.354,
"reg_Caco-2_spearman": 0.684,
"reg_Caco-2_mca": 0.082,
"reg_Solubility_mae": 0.412,
"reg_Solubility_spearman": 0.725,
"reg_Solubility_mca": 0.076,
"clf_hERG_accuracy": 0.845,
"clf_hERG_mca": 0.048,
"clf_AMES_accuracy": 0.812,
"clf_AMES_mca": 0.061,
"info": "Placeholder values (Model has not been trained locally yet. Run training to generate local metrics)."
}
from fastapi.staticfiles import StaticFiles
dist_path = os.path.abspath(os.path.join(os.path.dirname(__file__), 'dist'))
if os.path.exists(dist_path):
app.mount("/", StaticFiles(directory=dist_path, html=True), name="static")