Spaces:
Paused
Paused
Download src/explain.py from VSRAJHAVEL/BioGraph-AI: direct link, hf CLI and curl.
- Browser
- Download file 7.21 kB
-
https://huggingface.co/spaces/VSRAJHAVEL/BioGraph-AI/resolve/main/src/explain.py
- Command line
-
hf download hf://spaces/VSRAJHAVEL/BioGraph-AI/src/explain.py
-
curl -L -o explain.py https://huggingface.co/spaces/VSRAJHAVEL/BioGraph-AI/resolve/main/src/explain.py
7.21 kB
| """ | |
| Model Explainability Suite using GNNExplainer and RDKit 2D Substructure Rendering. | |
| Supports both single-task and multi-task models, with edge-aware and non-edge GNNs. | |
| """ | |
| import os | |
| import sys | |
| # Ensure project root is in sys.path | |
| sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) | |
| import logging | |
| import argparse | |
| from typing import Dict, List, Optional, Tuple, Any, Union | |
| import numpy as np | |
| import torch | |
| import torch.nn as nn | |
| from torch_geometric.explain import Explainer, GNNExplainer, ModelConfig | |
| from rdkit import Chem | |
| from rdkit.Chem.Draw import rdMolDraw2D | |
| import matplotlib.pyplot as plt | |
| from src.data import smiles_to_graph | |
| from src.models.gnn import build_gnn_model | |
| logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") | |
| logger = logging.getLogger(__name__) | |
| class TaskSpecificWrapper(nn.Module): | |
| """Wraps a multi-task GNN to isolate a single task output for GNNExplainer.""" | |
| def __init__(self, base_model: nn.Module, task_idx: int = 0): | |
| super().__init__() | |
| self.base_model = base_model | |
| self.task_idx = task_idx | |
| def forward( | |
| self, | |
| x: torch.Tensor, | |
| edge_index: torch.Tensor, | |
| edge_attr: Optional[torch.Tensor] = None, | |
| batch: Optional[torch.Tensor] = None | |
| ) -> torch.Tensor: | |
| out = self.base_model(x, edge_index, edge_attr=edge_attr, batch=batch) | |
| if out.ndim == 1: | |
| return out.view(-1, 1) | |
| return out[:, self.task_idx:self.task_idx + 1] | |
| def explain_molecule( | |
| model: nn.Module, | |
| smiles: str, | |
| task_type: str = 'regression', | |
| task_idx: int = 0, | |
| num_tasks: int = 1, | |
| num_epochs: int = 50, | |
| device: str = 'cpu' | |
| ) -> Tuple[np.ndarray, np.ndarray, float]: | |
| """ | |
| Explain a molecular graph prediction using GNNExplainer. | |
| Returns: | |
| atom_importances: np.ndarray of shape (num_atoms,) | |
| edge_importances: np.ndarray of shape (num_edges,) | |
| pred_value: float scalar prediction | |
| """ | |
| model.eval() | |
| graph = smiles_to_graph(smiles) | |
| if graph is None: | |
| raise ValueError(f"Invalid SMILES string: '{smiles}'") | |
| graph = graph.to(device) | |
| # Determine prediction | |
| with torch.no_grad(): | |
| raw_pred = model(graph.x, graph.edge_index, edge_attr=graph.edge_attr) | |
| if task_type != 'regression': | |
| raw_pred = torch.sigmoid(raw_pred) | |
| pred_scalar = float(raw_pred[0, task_idx].item()) if raw_pred.ndim > 1 else float(raw_pred[0].item()) | |
| # Wrap model if multi-task | |
| if num_tasks > 1: | |
| wrapped_model = TaskSpecificWrapper(model, task_idx=task_idx) | |
| else: | |
| wrapped_model = model | |
| mode = 'regression' if task_type == 'regression' else 'binary_classification' | |
| explainer = Explainer( | |
| model=wrapped_model, | |
| algorithm=GNNExplainer(epochs=num_epochs), | |
| explanation_type='model', | |
| node_mask_type='attributes', | |
| edge_mask_type='object' if graph.edge_index.size(1) > 0 else None, | |
| model_config=ModelConfig( | |
| mode=mode, | |
| task_level='graph', | |
| return_type='raw' | |
| ) | |
| ) | |
| explanation = explainer( | |
| x=graph.x, | |
| edge_index=graph.edge_index, | |
| edge_attr=graph.edge_attr | |
| ) | |
| # Atom importance: sum across feature attributes | |
| if explanation.node_mask is not None: | |
| atom_scores = explanation.node_mask.sum(dim=-1).detach().cpu().numpy() | |
| else: | |
| atom_scores = np.ones(graph.num_nodes, dtype=np.float32) | |
| # Edge importance | |
| if explanation.edge_mask is not None and graph.edge_index.size(1) > 0: | |
| edge_scores = explanation.edge_mask.detach().cpu().numpy() | |
| else: | |
| edge_scores = np.zeros(graph.edge_index.size(1), dtype=np.float32) | |
| return atom_scores, edge_scores, pred_scalar | |
| def render_molecule_explanation( | |
| smiles: str, | |
| atom_importances: np.ndarray, | |
| width: int = 500, | |
| height: int = 400, | |
| colormap_name: str = 'Reds', | |
| output_path: Optional[str] = None | |
| ) -> bytes: | |
| """ | |
| Render 2D molecular structure with atom-level importance heatmaps. | |
| Outputs high-resolution PNG bytes and optionally saves to disk. | |
| """ | |
| mol = Chem.MolFromSmiles(smiles) | |
| if mol is None: | |
| raise ValueError(f"Invalid SMILES string '{smiles}'") | |
| num_atoms = mol.GetNumAtoms() | |
| if len(atom_importances) != num_atoms: | |
| atom_scores = np.resize(atom_importances, num_atoms) | |
| else: | |
| atom_scores = np.array(atom_importances, dtype=np.float32) | |
| # Normalize scores between 0.15 and 0.95 for clear visual contrast | |
| min_s = float(atom_scores.min()) | |
| max_s = float(atom_scores.max()) | |
| range_s = max_s - min_s if max_s > min_s else 1.0 | |
| norm_scores = 0.15 + 0.8 * ((atom_scores - min_s) / range_s) | |
| cmap = plt.colormaps[colormap_name] | |
| atom_colors = {} | |
| radii = {} | |
| for i in range(num_atoms): | |
| rgba = cmap(float(norm_scores[i])) | |
| atom_colors[i] = (float(rgba[0]), float(rgba[1]), float(rgba[2])) | |
| radii[i] = 0.35 + 0.25 * float(norm_scores[i]) | |
| drawer = rdMolDraw2D.MolDraw2DCairo(width, height) | |
| draw_options = drawer.drawOptions() | |
| draw_options.useBWAtomPalette() | |
| draw_options.scaleBondWidth = True | |
| draw_options.bondLineWidth = 2 | |
| drawer.DrawMolecule( | |
| mol, | |
| highlightAtoms=list(atom_colors.keys()), | |
| highlightAtomColors=atom_colors, | |
| highlightAtomRadii=radii | |
| ) | |
| drawer.FinishDrawing() | |
| png_bytes = drawer.GetDrawingText() | |
| if output_path is not None: | |
| os.makedirs(os.path.dirname(output_path), exist_ok=True) | |
| with open(output_path, 'wb') as f: | |
| f.write(png_bytes) | |
| logger.info(f"Saved explanation rendering -> {output_path}") | |
| return png_bytes | |
| if __name__ == "__main__": | |
| parser = argparse.ArgumentParser(description="Explain GNN molecular property prediction") | |
| parser.add_argument("--smiles", type=str, default="CC(=O)OC1=CC=CC=C1C(=O)O", help="SMILES to explain") | |
| parser.add_argument("--model", type=str, default="gcn", help="Model name (gcn, gat, gin, mpnn)") | |
| parser.add_argument("--dataset", type=str, default="esol", help="Dataset name (esol, bbbp, tox21)") | |
| parser.add_argument("--task_idx", type=int, default=0, help="Task index to explain (0-11 for Tox21)") | |
| parser.add_argument("--output", type=str, default="results/plots/explanation_demo.png", help="Output PNG path") | |
| args = parser.parse_args() | |
| from src.evaluate import load_trained_model | |
| model, payload = load_trained_model(args.model, args.dataset) | |
| num_tasks = payload['num_tasks'] if payload else 1 | |
| task_type = 'regression' if args.dataset == 'esol' else ('multitask_classification' if args.dataset == 'tox21' else 'classification') | |
| atom_imp, edge_imp, pred = explain_molecule( | |
| model=model, | |
| smiles=args.smiles, | |
| task_type=task_type, | |
| task_idx=args.task_idx, | |
| num_tasks=num_tasks, | |
| num_epochs=30 | |
| ) | |
| print(f"Prediction for {args.smiles}: {pred:.4f}") | |
| print(f"Top 3 important atom indices: {np.argsort(atom_imp)[-3:][::-1]}") | |
| render_molecule_explanation(args.smiles, atom_imp, output_path=args.output) | |