BioGraph-AI / src /explain.py
VSRAJHAVEL's picture
Upload folder using huggingface_hub
2fd4f2f verified
Raw History Blame Contribute Delete
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)