""" 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}")