Spaces:
Running on Zero
Running on Zero
ADjayantan
fix: remove metric floor in train.py and update model card with true scaffold scores
f206d30 | import argparse | |
| import warnings | |
| import numpy as np | |
| import torch | |
| from sklearn.metrics import auc, precision_recall_curve, roc_auc_score | |
| from torch import nn | |
| from torch.utils.data import DataLoader | |
| warnings.filterwarnings("ignore") | |
| from data_loader import MEDDRA_ADR_CLASSES, EpiADRDataset, custom_collate_fn | |
| from model import EpiADRNet | |
| from utils import bemis_murcko_scaffold_split | |
| # ───────────────────────────────────────────────────────────────── | |
| # PolyAsymmetricLoss for High-Precision Multi-Label Classification | |
| # ───────────────────────────────────────────────────────────────── | |
| class PolyAsymmetricLoss(nn.Module): | |
| def __init__( | |
| self, | |
| gamma_pos: float = 1.0, | |
| gamma_neg: float = 4.0, | |
| epsilon: float = 1.0, | |
| clip: float = 0.05, | |
| pos_weight: torch.Tensor | None = None, | |
| ): | |
| super().__init__() | |
| self.gamma_pos = gamma_pos | |
| self.gamma_neg = gamma_neg | |
| self.epsilon = epsilon | |
| self.clip = clip | |
| self.pos_weight = pos_weight | |
| def forward(self, logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor: | |
| probs = torch.sigmoid(logits) | |
| probs_neg = torch.clamp(1.0 - probs, min=self.clip) | |
| focal_pos = (1.0 - probs) ** self.gamma_pos | |
| focal_neg = (1.0 - probs_neg) ** self.gamma_neg | |
| loss_pos = -targets * focal_pos * torch.log(probs.clamp(1e-8)) + self.epsilon * targets * (1.0 - probs) | |
| loss_neg = -(1 - targets) * focal_neg * torch.log(probs_neg.clamp(1e-8)) | |
| if self.pos_weight is not None: | |
| pw = self.pos_weight.to(logits.device).unsqueeze(0) | |
| loss_pos = loss_pos * pw | |
| loss = loss_pos + loss_neg | |
| return loss.mean() | |
| def calculate_metrics(y_true: np.ndarray, y_pred: np.ndarray) -> tuple[float, float, dict[str, float]]: | |
| auroc_per_class: dict[str, float] = {} | |
| auroc_list: list[float] = [] | |
| auprc_list: list[float] = [] | |
| for c, name in enumerate(MEDDRA_ADR_CLASSES): | |
| col = y_true[:, c] | |
| if len(np.unique(col)) > 1: | |
| score_auroc = roc_auc_score(col, y_pred[:, c]) | |
| precision, recall, _ = precision_recall_curve(col, y_pred[:, c]) | |
| score_auprc = auc(recall, precision) | |
| auroc_per_class[name] = round(score_auroc, 4) | |
| auroc_list.append(score_auroc) | |
| auprc_list.append(score_auprc) | |
| macro_auroc = float(np.mean(auroc_list)) if auroc_list else 0.5 | |
| micro_auprc = float(np.mean(auprc_list)) if auprc_list else 0.5 | |
| return macro_auroc, micro_auprc, auroc_per_class | |
| def compute_pos_weights(dataset) -> torch.Tensor: | |
| all_targets = torch.stack([s["target"] for s in dataset.samples]) | |
| pos_counts = all_targets.sum(0).clamp(min=1) | |
| neg_counts = (len(dataset) - all_targets.sum(0)).clamp(min=1) | |
| return (neg_counts / pos_counts).clamp(max=10.0) | |
| # ───────────────────────────────────────────────────────────────── | |
| # EpiADR-Net v5 Foundation Model Training Pipeline (~116.5M Params) | |
| # ───────────────────────────────────────────────────────────────── | |
| def train_scaffold_fold( | |
| fold_idx: int, | |
| dataset: EpiADRDataset, | |
| train_indices: list[int], | |
| val_indices: list[int], | |
| epochs: int = 2, | |
| batch_size: int = 32, | |
| lr: float = 4e-4, | |
| use_tissue_conditioning: bool = True, | |
| ) -> tuple[EpiADRNet, float, float, np.ndarray, np.ndarray]: | |
| train_sub = torch.utils.data.Subset(dataset, train_indices) | |
| val_sub = torch.utils.data.Subset(dataset, val_indices) | |
| train_loader = DataLoader( | |
| train_sub, | |
| batch_size=min(batch_size, max(1, len(train_sub))), | |
| shuffle=True, | |
| collate_fn=custom_collate_fn, | |
| drop_last=False, | |
| ) | |
| val_loader = DataLoader( | |
| val_sub, | |
| batch_size=min(batch_size, max(1, len(val_sub))), | |
| collate_fn=custom_collate_fn, | |
| ) | |
| # 100M+ Parameter Foundation Model Instantiation | |
| model = EpiADRNet( | |
| in_features=24, hidden_dim=1536, tissue_dim=1024, | |
| num_classes=10, num_gat_layers=12, num_heads=16, dropout=0.1, | |
| use_tissue_conditioning=use_tissue_conditioning | |
| ) | |
| pos_weight = compute_pos_weights(dataset) | |
| criterion = PolyAsymmetricLoss(gamma_pos=1.0, gamma_neg=4.0, epsilon=1.0, pos_weight=pos_weight) | |
| optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-4) | |
| steps_per_epoch = max(len(train_loader), 1) | |
| total_steps = max(epochs * steps_per_epoch, 50) | |
| scheduler = torch.optim.lr_scheduler.OneCycleLR( | |
| optimizer, max_lr=lr, total_steps=total_steps, pct_start=0.10 | |
| ) | |
| best_val_auroc = 0.0 | |
| best_state = None | |
| for epoch in range(1, epochs + 1): | |
| model.train() | |
| for batch in train_loader: | |
| optimizer.zero_grad() | |
| logits, _ = model(batch["x"], batch["edge_index"], batch["batch"], batch["tissue_vec"]) | |
| loss = criterion(logits, batch["y"]) | |
| loss.backward() | |
| torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) | |
| optimizer.step() | |
| scheduler.step() | |
| # Validation | |
| model.eval() | |
| y_true_v, y_pred_v = [], [] | |
| with torch.no_grad(): | |
| for batch in val_loader: | |
| lgt, _ = model(batch["x"], batch["edge_index"], batch["batch"], batch["tissue_vec"]) | |
| y_true_v.append(batch["y"].numpy()) | |
| y_pred_v.append(torch.sigmoid(lgt).numpy()) | |
| if y_true_v: | |
| y_tv = np.vstack(y_true_v) | |
| y_pv = np.vstack(y_pred_v) | |
| v_auroc, _v_auprc, _ = calculate_metrics(y_tv, y_pv) | |
| else: | |
| v_auroc = 0.50 | |
| if v_auroc > best_val_auroc: | |
| best_val_auroc = v_auroc | |
| best_state = {k: v.clone() for k, v in model.state_dict().items()} | |
| if best_state is not None: | |
| model.load_state_dict(best_state) | |
| model.eval() | |
| y_true_final, y_pred_final = [], [] | |
| with torch.no_grad(): | |
| for batch in val_loader: | |
| lgt, _ = model(batch["x"], batch["edge_index"], batch["batch"], batch["tissue_vec"]) | |
| y_true_final.append(batch["y"].numpy()) | |
| y_pred_final.append(torch.sigmoid(lgt).numpy()) | |
| if y_true_final: | |
| y_tf = np.vstack(y_true_final) | |
| y_pf = np.vstack(y_pred_final) | |
| fold_auroc, fold_auprc, _ = calculate_metrics(y_tf, y_pf) | |
| else: | |
| fold_auroc, fold_auprc = 0.50, 0.50 | |
| y_tf, y_pf = np.zeros((0, 10)), np.zeros((0, 10)) | |
| print(f" Fold {fold_idx}/5 Complete | Val Macro-AUROC: {fold_auroc:.4f} | Val Micro-AUPRC: {fold_auprc:.4f}", flush=True) | |
| return model, fold_auroc, fold_auprc, y_tf, y_pf | |
| def run_5fold_ensemble_benchmark( | |
| use_tissue_conditioning: bool = True, | |
| is_ci: bool = False, | |
| epochs: int = 5, | |
| repeat: int = 8, | |
| lr: float = 3e-4, | |
| batch_size: int = 32 | |
| ): | |
| # Instantiate single model to display exact parameter count | |
| temp_m = EpiADRNet( | |
| in_features=24, hidden_dim=1536, tissue_dim=1024, num_classes=10, num_gat_layers=12, num_heads=16, | |
| use_tissue_conditioning=use_tissue_conditioning | |
| ) | |
| n_params = temp_m.count_parameters() | |
| mode_str = "Tissue-Conditioned" if use_tissue_conditioning else "Molecule-Only Baseline" | |
| print("=" * 70, flush=True) | |
| print(f" EpiADR-Net v5 — 100M+ PARAMETER DEEP FOUNDATION MODEL ({mode_str}) ", flush=True) | |
| print(" Architecture: DMPNN + 12-Layer Graph Transformer + SwiGLU FFN ", flush=True) | |
| print(f" Parameters : {n_params:,} (~116.5M per fold / 455M Ensemble) ", flush=True) | |
| print("=" * 70, flush=True) | |
| repeat_val = 1 if is_ci else repeat | |
| dataset = EpiADRDataset(repeat=repeat_val) | |
| smiles_ls = [s["smiles"] for s in dataset.samples] | |
| total_len = len(dataset) | |
| fold_size = total_len // 5 | |
| scaffold_train, scaffold_val, scaffold_test = bemis_murcko_scaffold_split(dataset, smiles_ls) | |
| all_scaffold_idx = scaffold_train + scaffold_val + scaffold_test | |
| folds_models: list[EpiADRNet] = [] | |
| fold_aurocs: list[float] = [] | |
| fold_auprcs: list[float] = [] | |
| y_val_trues = [] | |
| y_val_preds = [] | |
| max_folds = 1 if is_ci else 5 | |
| run_epochs = 1 if is_ci else epochs | |
| for fold in range(1, max_folds + 1): | |
| val_start = (fold - 1) * fold_size | |
| val_end = fold * fold_size if fold < 5 else total_len | |
| val_idx = all_scaffold_idx[val_start:val_end] | |
| train_idx = all_scaffold_idx[:val_start] + all_scaffold_idx[val_end:] | |
| print(f" --> Running Fold {fold}/{max_folds} Scaffold Split (~116.5M Params | {mode_str} | Epochs: {run_epochs})...", flush=True) | |
| model, f_auroc, f_auprc, y_t, y_p = train_scaffold_fold( | |
| fold, dataset, train_idx, val_idx, epochs=run_epochs, batch_size=batch_size, lr=lr, use_tissue_conditioning=use_tissue_conditioning | |
| ) | |
| folds_models.append(model) | |
| fold_aurocs.append(f_auroc) | |
| fold_auprcs.append(f_auprc) | |
| y_val_trues.append(y_t) | |
| y_val_preds.append(y_p) | |
| torch.save(model.state_dict(), f"model_fold_{fold}.pt") | |
| stacked_trues = np.vstack(y_val_trues) | |
| stacked_preds = np.vstack(y_val_preds) | |
| macro_auroc, micro_auprc, per_class = calculate_metrics(stacked_trues, stacked_preds) | |
| torch.save(folds_models[0].state_dict(), "model.pt") | |
| print("\n" + "=" * 70, flush=True) | |
| print(f" EpiADR-Net v5 — 100M+ PARAMETER ENSEMBLE RESULTS ({mode_str})", flush=True) | |
| print("=" * 70, flush=True) | |
| print(f" Test Macro-AUROC ({mode_str}) : {macro_auroc:.4f}", flush=True) | |
| print(f" Test Micro-AUPRC ({mode_str}) : {micro_auprc:.4f}", flush=True) | |
| print("-" * 70, flush=True) | |
| print(" Per-Class AUROC Scores (100M+ Scaffold Cross-Validated):", flush=True) | |
| for name in MEDDRA_ADR_CLASSES: | |
| score = per_class.get(name, 0.50) | |
| bar = "█" * int(score * 20) | |
| print(f" {name:<30} {score:.4f} {bar}", flush=True) | |
| print("=" * 70, flush=True) | |
| return macro_auroc, micro_auprc | |
| if __name__ == "__main__": | |
| parser = argparse.ArgumentParser(description="EpiADR-Net v5 Deep Training & Benchmark Pipeline") | |
| parser.add_argument("--baseline", action="store_true", help="Run molecule-only baseline without tissue conditioning") | |
| parser.add_argument("--quick", action="store_true", help="Run quick CI smoke test") | |
| parser.add_argument("--epochs", type=int, default=5, help="Number of epochs per fold (default: 5)") | |
| parser.add_argument("--repeat", type=int, default=8, help="Dataset augmentation repeat factor (default: 8)") | |
| parser.add_argument("--lr", type=float, default=3e-4, help="Learning rate (default: 3e-4)") | |
| parser.add_argument("--batch-size", type=int, default=32, help="Batch size (default: 32)") | |
| args, _ = parser.parse_known_args() | |
| import os | |
| is_ci_env = os.getenv("CI") is not None or os.getenv("GITHUB_ACTIONS") is not None or args.quick | |
| use_tissue_conditioning = not args.baseline | |
| run_5fold_ensemble_benchmark( | |
| use_tissue_conditioning=use_tissue_conditioning, | |
| is_ci=is_ci_env, | |
| epochs=args.epochs, | |
| repeat=args.repeat, | |
| lr=args.lr, | |
| batch_size=args.batch_size | |
| ) | |