Spaces:
Paused
Paused
Download src/evaluate.py from VSRAJHAVEL/BioGraph-AI: direct link, hf CLI and curl.
- Browser
- Download file 15.4 kB
-
https://huggingface.co/spaces/VSRAJHAVEL/BioGraph-AI/resolve/main/src/evaluate.py
- Command line
-
hf download hf://spaces/VSRAJHAVEL/BioGraph-AI/src/evaluate.py
-
curl -L -o evaluate.py https://huggingface.co/spaces/VSRAJHAVEL/BioGraph-AI/resolve/main/src/evaluate.py
15.4 kB
| """ | |
| Evaluation and Diagnostics Suite for GNNs and Classical Baselines. | |
| Generates parity plots, ROC/PR curves, loss trajectories, t-SNE latent embeddings, | |
| and compiles results/REPORT.md. | |
| """ | |
| 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 json | |
| import logging | |
| from typing import Dict, List, Any, Optional, Tuple | |
| import yaml | |
| import numpy as np | |
| import torch | |
| import matplotlib.pyplot as plt | |
| import seaborn as sns | |
| from sklearn.manifold import TSNE | |
| from sklearn.metrics import ( | |
| mean_squared_error, mean_absolute_error, r2_score, | |
| roc_curve, precision_recall_curve, roc_auc_score, average_precision_score | |
| ) | |
| from src.data import get_dataloaders, NODE_DIM, EDGE_DIM | |
| from src.models.gnn import build_gnn_model | |
| from src.models.baseline import ClassicalFingerprintBaseline, extract_features | |
| logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") | |
| logger = logging.getLogger(__name__) | |
| # Matplotlib styling for publication quality | |
| plt.style.use('seaborn-v0_8-whitegrid' if 'seaborn-v0_8-whitegrid' in plt.style.available else 'default') | |
| plt.rcParams['font.sans-serif'] = 'DejaVu Sans' | |
| plt.rcParams['axes.edgecolor'] = '#cccccc' | |
| plt.rcParams['axes.linewidth'] = 0.8 | |
| def load_trained_model( | |
| model_name: str, | |
| dataset_name: str, | |
| checkpoint_dir: str = 'results/checkpoints' | |
| ) -> Tuple[Any, Optional[Dict[str, Any]]]: | |
| """Load trained model checkpoint from disk.""" | |
| model_name = model_name.lower() | |
| dataset_name = dataset_name.lower() | |
| if model_name == 'baseline': | |
| if os.environ.get("LOW_MEMORY_MODE") == "1" and dataset_name == "tox21": | |
| # Avoid deserializing 650MB Tox21 Random Forest pickle on 512MB cloud free-tier VMs | |
| raise RuntimeError("Tox21 RF baseline disabled in LOW_MEMORY_MODE to preserve RAM limit.") | |
| ckpt_path = os.path.join(checkpoint_dir, f"{dataset_name}_baseline_best.pkl") | |
| if not os.path.exists(ckpt_path): | |
| raise FileNotFoundError(f"Baseline checkpoint not found at {ckpt_path}") | |
| return ClassicalFingerprintBaseline.load(ckpt_path), None | |
| else: | |
| ckpt_path = os.path.join(checkpoint_dir, f"{dataset_name}_{model_name}_best.pt") | |
| if not os.path.exists(ckpt_path): | |
| raise FileNotFoundError(f"GNN checkpoint not found at {ckpt_path}") | |
| payload = torch.load(ckpt_path, weights_only=False) | |
| kwargs = payload['model_kwargs'] | |
| model = build_gnn_model(model_name=model_name, **kwargs) | |
| model.load_state_dict(payload['model_state_dict']) | |
| model.eval() | |
| return model, payload | |
| def plot_loss_trajectories(dataset_name: str, history_dir: str = 'results/history', save_dir: str = 'results/plots'): | |
| """Plot training loss curves across GNN architectures.""" | |
| os.makedirs(save_dir, exist_ok=True) | |
| models = ['gcn', 'gat', 'gin', 'mpnn'] | |
| colors = {'gcn': '#1f77b4', 'gat': '#ff7f0e', 'gin': '#2ca02c', 'mpnn': '#d62728'} | |
| plt.figure(figsize=(8, 5)) | |
| has_data = False | |
| for m in models: | |
| hist_path = os.path.join(history_dir, f"{dataset_name}_{m}_history.json") | |
| if os.path.exists(hist_path): | |
| with open(hist_path, 'r') as f: | |
| hist = json.load(f) | |
| losses = hist.get('train_loss', []) | |
| if losses: | |
| plt.plot(range(1, len(losses) + 1), losses, label=m.upper(), color=colors.get(m, '#333333'), lw=2.0) | |
| has_data = True | |
| if has_data: | |
| plt.xlabel("Epoch", fontsize=11, fontweight='bold') | |
| plt.ylabel("Training Loss", fontsize=11, fontweight='bold') | |
| plt.title(f"Training Loss Trajectory on {dataset_name.upper()} (CPU-Optimized)", fontsize=12, fontweight='bold') | |
| plt.legend(frameon=True) | |
| plt.tight_layout() | |
| plot_path = os.path.join(save_dir, f"{dataset_name}_loss_curves.png") | |
| plt.savefig(plot_path, dpi=300) | |
| plt.close() | |
| logger.info(f"Saved loss trajectory plot -> {plot_path}") | |
| def plot_parity( | |
| dataset_name: str, | |
| model_name: str = 'mpnn', | |
| checkpoint_dir: str = 'results/checkpoints', | |
| save_dir: str = 'results/plots' | |
| ): | |
| """Generate Parity Plot (Predicted vs Actual) for ESOL Regression.""" | |
| os.makedirs(save_dir, exist_ok=True) | |
| _, _, test_loader, meta = get_dataloaders(dataset_name, batch_size=32) | |
| test_data = meta['test'] | |
| model, _ = load_trained_model(model_name, dataset_name, checkpoint_dir) | |
| if model_name == 'baseline': | |
| preds = model.predict(test_data).flatten() | |
| _, y_true = extract_features(test_data) | |
| y_true = y_true.flatten() | |
| else: | |
| preds_list, targets_list = [], [] | |
| with torch.no_grad(): | |
| for batch in test_loader: | |
| out = model(batch.x, batch.edge_index, edge_attr=batch.edge_attr, batch=batch.batch) | |
| preds_list.append(out.view(-1).numpy()) | |
| targets_list.append(batch.y.view(-1).numpy()) | |
| preds = np.concatenate(preds_list) | |
| y_true = np.concatenate(targets_list) | |
| rmse = np.sqrt(mean_squared_error(y_true, preds)) | |
| r2 = r2_score(y_true, preds) | |
| plt.figure(figsize=(6.5, 6)) | |
| plt.scatter(y_true, preds, alpha=0.7, color='#2b5c8f', edgecolors='k', linewidth=0.5, s=45, label='Test Compounds') | |
| min_val = min(y_true.min(), preds.min()) - 0.5 | |
| max_val = max(y_true.max(), preds.max()) + 0.5 | |
| plt.plot([min_val, max_val], [min_val, max_val], 'r--', lw=1.8, label='Ideal 1:1 Parity') | |
| plt.xlim(min_val, max_val) | |
| plt.ylim(min_val, max_val) | |
| plt.xlabel("Measured Aqueous Solubility logS (mol/L)", fontsize=11, fontweight='bold') | |
| plt.ylabel(f"Predicted logS by {model_name.upper()} (mol/L)", fontsize=11, fontweight='bold') | |
| plt.title(f"ESOL Parity Plot ({model_name.upper()} on Scaffold Test Split)\nRMSE: {rmse:.3f} | R²: {r2:.3f}", fontsize=12, fontweight='bold') | |
| plt.legend(frameon=True) | |
| plt.tight_layout() | |
| plot_path = os.path.join(save_dir, f"{dataset_name}_parity_{model_name}.png") | |
| plt.savefig(plot_path, dpi=300) | |
| plt.close() | |
| logger.info(f"Saved parity plot -> {plot_path}") | |
| def plot_roc_pr_curves( | |
| dataset_name: str, | |
| checkpoint_dir: str = 'results/checkpoints', | |
| save_dir: str = 'results/plots' | |
| ): | |
| """Generate ROC and PR Curves for BBBP or Tox21 classification.""" | |
| os.makedirs(save_dir, exist_ok=True) | |
| models = ['baseline', 'gcn', 'gat', 'gin', 'mpnn'] | |
| colors = {'baseline': '#7f7f7f', 'gcn': '#1f77b4', 'gat': '#ff7f0e', 'gin': '#2ca02c', 'mpnn': '#d62728'} | |
| _, _, test_loader, meta = get_dataloaders(dataset_name, batch_size=32) | |
| test_data = meta['test'] | |
| fig, (ax_roc, ax_pr) = plt.subplots(1, 2, figsize=(13, 5.5)) | |
| for m in models: | |
| try: | |
| model, _ = load_trained_model(m, dataset_name, checkpoint_dir) | |
| except Exception: | |
| continue | |
| if m == 'baseline': | |
| preds = model.predict(test_data)[:, 0] | |
| _, y_true = extract_features(test_data) | |
| y_true = y_true[:, 0] | |
| else: | |
| preds_list, targets_list = [], [] | |
| with torch.no_grad(): | |
| for batch in test_loader: | |
| out = torch.sigmoid(model(batch.x, batch.edge_index, edge_attr=batch.edge_attr, batch=batch.batch)) | |
| preds_list.append(out[:, 0].numpy()) | |
| targets_list.append(batch.y[:, 0].numpy()) | |
| preds = np.concatenate(preds_list) | |
| y_true = np.concatenate(targets_list) | |
| mask = ~np.isnan(y_true) | |
| y_eval = y_true[mask] | |
| p_eval = preds[mask] | |
| if len(np.unique(y_eval)) > 1: | |
| fpr, tpr, _ = roc_curve(y_eval, p_eval) | |
| roc_auc = roc_auc_score(y_eval, p_eval) | |
| prec, rec, _ = precision_recall_curve(y_eval, p_eval) | |
| pr_auc = average_precision_score(y_eval, p_eval) | |
| c = colors.get(m, '#333333') | |
| ax_roc.plot(fpr, tpr, label=f"{m.upper()} (AUC = {roc_auc:.3f})", color=c, lw=2.0) | |
| ax_pr.plot(rec, prec, label=f"{m.upper()} (AUC = {pr_auc:.3f})", color=c, lw=2.0) | |
| ax_roc.plot([0, 1], [0, 1], 'k--', lw=1.2, label='Random Chance') | |
| ax_roc.set_xlabel("False Positive Rate", fontsize=11, fontweight='bold') | |
| ax_roc.set_ylabel("True Positive Rate", fontsize=11, fontweight='bold') | |
| ax_roc.set_title(f"ROC Curves on {dataset_name.upper()} Test Split", fontsize=12, fontweight='bold') | |
| ax_roc.legend(loc='lower right', frameon=True) | |
| ax_pr.set_xlabel("Recall", fontsize=11, fontweight='bold') | |
| ax_pr.set_ylabel("Precision", fontsize=11, fontweight='bold') | |
| ax_pr.set_title(f"Precision-Recall Curves on {dataset_name.upper()} Test Split", fontsize=12, fontweight='bold') | |
| ax_pr.legend(loc='lower left', frameon=True) | |
| plt.tight_layout() | |
| plot_path = os.path.join(save_dir, f"{dataset_name}_roc_pr.png") | |
| plt.savefig(plot_path, dpi=300) | |
| plt.close() | |
| logger.info(f"Saved ROC/PR curves -> {plot_path}") | |
| def plot_tsne_embeddings( | |
| dataset_name: str, | |
| model_name: str = 'mpnn', | |
| checkpoint_dir: str = 'results/checkpoints', | |
| save_dir: str = 'results/plots' | |
| ): | |
| """Extract latent embeddings and project into 2D with t-SNE.""" | |
| os.makedirs(save_dir, exist_ok=True) | |
| _, _, test_loader, meta = get_dataloaders(dataset_name, batch_size=32) | |
| try: | |
| model, _ = load_trained_model(model_name, dataset_name, checkpoint_dir) | |
| except Exception as e: | |
| logger.warning(f"Could not load {model_name} for t-SNE: {e}") | |
| return | |
| embeddings_list, targets_list = [], [] | |
| with torch.no_grad(): | |
| for batch in test_loader: | |
| emb = model.get_embedding(batch.x, batch.edge_index, edge_attr=batch.edge_attr, batch=batch.batch) | |
| embeddings_list.append(emb.numpy()) | |
| targets_list.append(batch.y[:, 0].numpy()) | |
| embeddings = np.concatenate(embeddings_list, axis=0) | |
| targets = np.concatenate(targets_list, axis=0) | |
| # Filter out NaNs if any | |
| mask = ~np.isnan(targets) | |
| embeddings = embeddings[mask] | |
| targets = targets[mask] | |
| # Run t-SNE | |
| perplexity = min(30, max(5, len(embeddings) // 4)) | |
| tsne = TSNE(n_components=2, perplexity=perplexity, random_state=42) | |
| embeddings_2d = tsne.fit_transform(embeddings) | |
| plt.figure(figsize=(7, 6)) | |
| if dataset_name.lower() == 'esol': | |
| sc = plt.scatter(embeddings_2d[:, 0], embeddings_2d[:, 1], c=targets, cmap='viridis', s=50, alpha=0.85, edgecolors='k', linewidth=0.3) | |
| cbar = plt.colorbar(sc) | |
| cbar.set_label("Solubility logS (mol/L)", fontsize=10, fontweight='bold') | |
| else: | |
| # Binary classification | |
| 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)') | |
| 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)') | |
| plt.legend(frameon=True) | |
| plt.title(f"t-SNE Latent Space ({model_name.upper()} on {dataset_name.upper()} Test Split)", fontsize=12, fontweight='bold') | |
| plt.xlabel("t-SNE Dimension 1", fontsize=11, fontweight='bold') | |
| plt.ylabel("t-SNE Dimension 2", fontsize=11, fontweight='bold') | |
| plt.tight_layout() | |
| plot_path = os.path.join(save_dir, f"{dataset_name}_tsne_{model_name}.png") | |
| plt.savefig(plot_path, dpi=300) | |
| plt.close() | |
| logger.info(f"Saved t-SNE visualization -> {plot_path}") | |
| def generate_benchmark_report( | |
| benchmark_results: Dict[str, Dict[str, Any]], | |
| output_path: str = 'results/REPORT.md' | |
| ): | |
| """Compile comprehensive benchmark report across datasets and models.""" | |
| os.makedirs(os.path.dirname(output_path), exist_ok=True) | |
| lines = [ | |
| "# Benchmark Report: Graph Neural Networks for Molecular Property Prediction", | |
| "", | |
| "## Executive Summary", | |
| "This benchmark evaluates 5 distinct molecular property prediction architectures across 3 canonical MoleculeNet benchmarks using **strict Bemis-Murcko scaffold splitting (80/10/10)**.", | |
| "All models were trained and benchmarked locally on an Intel CPU with 4 physical threads.", | |
| "", | |
| "---", | |
| "", | |
| "## Benchmark Results", | |
| "" | |
| ] | |
| for dataset_name, models_dict in benchmark_results.items(): | |
| lines.append(f"### Dataset: `{dataset_name.upper()}`") | |
| if dataset_name.lower() == 'esol': | |
| lines.append("| Model Architecture | Test RMSE (mol/L) ↓ | Test MAE (mol/L) ↓ | Test R² ↑ | Train Time (s) |") | |
| lines.append("| :--- | :--- | :--- | :--- | :--- |") | |
| for m, data in models_dict.items(): | |
| agg = data['aggregated_test_metrics'] | |
| rmse_str = f"{agg['rmse']['mean']:.3f} ± {agg['rmse']['std']:.3f}" | |
| mae_str = f"{agg['mae']['mean']:.3f} ± {agg['mae']['std']:.3f}" | |
| r2_str = f"{agg['r2']['mean']:.3f} ± {agg['r2']['std']:.3f}" | |
| time_str = f"{data['mean_train_time_sec']:.1f}s" | |
| lines.append(f"| **{m.upper()}** | {rmse_str} | {mae_str} | {r2_str} | {time_str} |") | |
| else: | |
| lines.append("| Model Architecture | Test ROC-AUC ↑ | Test PR-AUC ↑ | Train Time (s) |") | |
| lines.append("| :--- | :--- | :--- | :--- |") | |
| for m, data in models_dict.items(): | |
| agg = data['aggregated_test_metrics'] | |
| roc_str = f"{agg['roc_auc']['mean']:.3f} ± {agg['roc_auc']['std']:.3f}" | |
| pr_str = f"{agg['pr_auc']['mean']:.3f} ± {agg['pr_auc']['std']:.3f}" | |
| time_str = f"{data['mean_train_time_sec']:.1f}s" | |
| lines.append(f"| **{m.upper()}** | {roc_str} | {pr_str} | {time_str} |") | |
| lines.append("") | |
| lines.extend([ | |
| "---", | |
| "", | |
| "## Inductive Biases & Architectural Insights", | |
| "", | |
| "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.", | |
| "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.", | |
| "3. **Graph Isomorphism (GIN)**: Maximally expressive for distinguishing graph topologies via multi-layer perceptron aggregations.", | |
| "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.", | |
| "", | |
| "---", | |
| "", | |
| "## Diagnostic Plots", | |
| "- **Training Trajectories**: `results/plots/{dataset}_loss_curves.png`", | |
| "- **Parity Plots**: `results/plots/esol_parity_{model}.png`", | |
| "- **ROC & PR Curves**: `results/plots/{dataset}_roc_pr.png`", | |
| "- **t-SNE Embeddings**: `results/plots/{dataset}_tsne_{model}.png`" | |
| ]) | |
| with open(output_path, 'w') as f: | |
| f.write("\n".join(lines)) | |
| logger.info(f"Report compiled successfully -> {output_path}") | |