| """Unified training utilities for neural and graph models."""
|
|
|
| from __future__ import annotations
|
|
|
| import math
|
| import os
|
| from typing import Any, Dict, List, Optional, Tuple, Type
|
|
|
| import numpy as np
|
| import torch
|
| import torch.nn as nn
|
| from sklearn.metrics import mean_absolute_error, r2_score
|
| from torch.optim import AdamW
|
| from torch.optim.lr_scheduler import CosineAnnealingLR, LambdaLR, ReduceLROnPlateau
|
| from torch_geometric.loader import DataLoader
|
|
|
| from .models import GATModel, HybridModel
|
| from .neural_models import DescriptorNN, FingerprintNN
|
|
|
|
|
| class NeuralNetworkTrainer:
|
| """Utility helper to train descriptor or fingerprint networks with scaling."""
|
|
|
| def __init__(self, model: nn.Module, device: torch.device, lr: float, weight_decay: float) -> None:
|
| self.model = model.to(device)
|
| self.device = device
|
| self.base_lr = lr
|
| self.optimizer = AdamW(
|
| self.model.parameters(), lr=lr, weight_decay=weight_decay, betas=(0.9, 0.999)
|
| )
|
| self.criterion = nn.SmoothL1Loss(beta=1.0)
|
| self.scheduler: CosineAnnealingLR | None = None
|
|
|
| def train_fold(
|
| self,
|
| train_features: np.ndarray,
|
| train_targets: np.ndarray,
|
| val_features: np.ndarray,
|
| val_targets: np.ndarray,
|
| epochs: int,
|
| batch_size: int,
|
| patience: int,
|
| gradient_clip: float,
|
| warmup_epochs: int = 10,
|
| *,
|
| train_lab_indices: Optional[np.ndarray] = None,
|
| val_lab_indices: Optional[np.ndarray] = None,
|
| verbose: bool = True,
|
| ) -> Tuple[Dict[str, float], np.ndarray]:
|
| device = self.device
|
|
|
|
|
| y_mean = float(train_targets.mean())
|
| y_std_raw = float(train_targets.std())
|
| y_std = y_std_raw if y_std_raw > 1e-6 else 1.0
|
|
|
| train_y_scaled = (train_targets - y_mean) / y_std
|
| val_y_scaled = (val_targets - y_mean) / y_std
|
|
|
| train_features_tensor = torch.tensor(train_features, dtype=torch.float32)
|
| train_targets_tensor = torch.tensor(train_y_scaled, dtype=torch.float32)
|
| val_features_tensor = torch.tensor(val_features, dtype=torch.float32)
|
| val_targets_tensor = torch.tensor(val_y_scaled, dtype=torch.float32)
|
|
|
| if train_lab_indices is not None:
|
| train_lab_np = np.asarray(train_lab_indices).reshape(-1)
|
| train_lab_tensor = torch.tensor(train_lab_np, dtype=torch.long)
|
| train_dataset = torch.utils.data.TensorDataset(
|
| train_features_tensor,
|
| train_lab_tensor,
|
| train_targets_tensor,
|
| )
|
| else:
|
| train_dataset = torch.utils.data.TensorDataset(
|
| train_features_tensor,
|
| train_targets_tensor,
|
| )
|
|
|
| labs_in_use = train_lab_indices is not None
|
|
|
| val_lab_tensor = (
|
| torch.tensor(np.asarray(val_lab_indices).reshape(-1), dtype=torch.long)
|
| if val_lab_indices is not None
|
| else None
|
| )
|
|
|
| train_loader = torch.utils.data.DataLoader(
|
| train_dataset, batch_size=batch_size, shuffle=True, drop_last=False
|
| )
|
|
|
| if epochs <= warmup_epochs:
|
| warmup_epochs = max(0, epochs - 1)
|
|
|
| self.scheduler = CosineAnnealingLR(
|
| self.optimizer,
|
| T_max=max(1, epochs - warmup_epochs),
|
| eta_min=self.base_lr * 0.05,
|
| )
|
|
|
| best_state: Dict[str, Any] | None = None
|
| best_mae = float("inf")
|
| patience_counter = 0
|
|
|
| for epoch in range(epochs):
|
| self.model.train()
|
| epoch_losses: List[float] = []
|
|
|
| for batch in train_loader:
|
| if labs_in_use:
|
| batch_x, batch_lab, batch_y = batch
|
| batch_lab = batch_lab.to(device)
|
| else:
|
| batch_x, batch_y = batch
|
| batch_lab = None
|
|
|
| batch_x = batch_x.to(device)
|
| batch_y = batch_y.to(device)
|
|
|
| self.optimizer.zero_grad(set_to_none=True)
|
| preds = (
|
| self.model(batch_x, batch_lab)
|
| if batch_lab is not None
|
| else self.model(batch_x)
|
| )
|
| loss = self.criterion(preds, batch_y)
|
| loss.backward()
|
| if gradient_clip:
|
| torch.nn.utils.clip_grad_norm_(self.model.parameters(), gradient_clip)
|
| self.optimizer.step()
|
| epoch_losses.append(loss.item())
|
|
|
|
|
| if epoch >= warmup_epochs and self.scheduler is not None:
|
| self.scheduler.step()
|
|
|
|
|
| self.model.eval()
|
| with torch.no_grad():
|
| val_preds_scaled = (
|
| self.model(val_features_tensor.to(device), val_lab_tensor.to(device))
|
| if val_lab_tensor is not None
|
| else self.model(val_features_tensor.to(device))
|
| ).cpu()
|
| val_preds = val_preds_scaled.numpy() * y_std + y_mean
|
| val_targets_unscaled = val_targets
|
|
|
| current_mae = mean_absolute_error(val_targets_unscaled, val_preds)
|
| current_r2 = r2_score(val_targets_unscaled, val_preds)
|
|
|
| improved = current_mae + 1e-5 < best_mae
|
| if improved:
|
| best_mae = current_mae
|
| patience_counter = 0
|
| best_state = {
|
| "model": self.model.state_dict(),
|
| "y_mean": y_mean,
|
| "y_std": y_std,
|
| "epoch": epoch + 1,
|
| "mae": current_mae,
|
| "r2": current_r2,
|
| }
|
| else:
|
| patience_counter += 1
|
|
|
| if verbose and ((epoch + 1) % 50 == 0 or improved):
|
| lr = self.optimizer.param_groups[0]["lr"]
|
| print(
|
| f" Epoch {epoch+1}: TrainLoss={np.mean(epoch_losses):.4f} "
|
| f"ValMAE={current_mae:.4f} ValR²={current_r2:.4f} LR={lr:.2e}" |
| f"{' *' if improved else ''}"
|
| )
|
|
|
| if patience_counter >= patience:
|
| if verbose:
|
| best_epoch = best_state["epoch"] if best_state else "N/A"
|
| print(f" Early stopping at epoch {epoch+1} (best epoch={best_epoch})")
|
| break
|
|
|
| if best_state is None:
|
| raise RuntimeError("Training failed to record a best state.")
|
|
|
|
|
| self.model.load_state_dict(best_state["model"])
|
| self.model.target_mean = best_state["y_mean"]
|
| self.model.target_std = best_state["y_std"]
|
|
|
| with torch.no_grad():
|
| final_preds_scaled = (
|
| self.model(val_features_tensor.to(device), val_lab_tensor.to(device))
|
| if val_lab_tensor is not None
|
| else self.model(val_features_tensor.to(device))
|
| ).cpu().numpy()
|
|
|
| final_preds = final_preds_scaled * self.model.target_std + self.model.target_mean
|
|
|
| metrics = {
|
| "r2": best_state["r2"],
|
| "mae": best_state["mae"],
|
| "best_epoch": best_state["epoch"],
|
| }
|
|
|
| return metrics, final_preds
|
|
|
| def save(self, path: str) -> None:
|
| torch.save(
|
| {
|
| "model_state": self.model.state_dict(),
|
| "target_mean": self.model.target_mean,
|
| "target_std": self.model.target_std,
|
| },
|
| path,
|
| )
|
|
|
| def predict(self, features: np.ndarray, lab_indices: Optional[np.ndarray] = None) -> np.ndarray:
|
| self.model.eval()
|
| with torch.no_grad():
|
| feature_tensor = torch.tensor(features, dtype=torch.float32)
|
| if lab_indices is not None:
|
| lab_tensor = torch.tensor(np.asarray(lab_indices).reshape(-1), dtype=torch.long)
|
| preds = self.model(
|
| feature_tensor.to(self.device),
|
| lab_tensor.to(self.device),
|
| ).cpu().numpy()
|
| else:
|
| preds = self.model(feature_tensor.to(self.device)).cpu().numpy()
|
| return preds * self.model.target_std + self.model.target_mean
|
|
|
|
|
| def _ensure_dir(path: str) -> None:
|
| os.makedirs(path, exist_ok=True)
|
|
|
|
|
| def train_descriptor_nn_fold(
|
| *,
|
| train_features: np.ndarray,
|
| val_features: np.ndarray,
|
| train_targets: np.ndarray,
|
| val_targets: np.ndarray,
|
| fold_idx: int,
|
| device: torch.device,
|
| config: Dict[str, Any],
|
| training_config: Dict[str, Any],
|
| save_dir: str = "oof_models",
|
| train_lab_indices: Optional[np.ndarray] = None,
|
| val_lab_indices: Optional[np.ndarray] = None,
|
| ) -> Tuple[DescriptorNN, np.ndarray, Dict[str, float]]:
|
| """Train Descriptor Neural Network for one CV fold."""
|
|
|
| print(f" Training Descriptor NN (Fold {fold_idx})...")
|
|
|
| model = DescriptorNN(**config)
|
| trainer = NeuralNetworkTrainer(
|
| model=model,
|
| device=device,
|
| lr=training_config["lr"],
|
| weight_decay=training_config["weight_decay"],
|
| )
|
|
|
| metrics, val_predictions = trainer.train_fold(
|
| train_features=train_features,
|
| train_targets=train_targets,
|
| val_features=val_features,
|
| val_targets=val_targets,
|
| epochs=training_config["epochs"],
|
| batch_size=training_config["batch_size"],
|
| patience=training_config["patience"],
|
| gradient_clip=training_config["gradient_clip"],
|
| warmup_epochs=training_config.get("warmup_epochs", 10),
|
| train_lab_indices=train_lab_indices,
|
| val_lab_indices=val_lab_indices,
|
| verbose=True,
|
| )
|
|
|
| _ensure_dir(save_dir)
|
| save_path = os.path.join(save_dir, f"desc_nn_fold_{fold_idx}.pt")
|
| trainer.save(save_path)
|
|
|
| print(
|
| f" Descriptor NN Fold {fold_idx}: R² = {metrics['r2']:.4f}, " |
| f"MAE = {metrics['mae']:.4f}, Best Epoch = {metrics['best_epoch']}"
|
| )
|
|
|
| return model, val_predictions, metrics
|
|
|
|
|
| def train_fingerprint_nn_fold(
|
| *,
|
| train_fingerprints: np.ndarray,
|
| val_fingerprints: np.ndarray,
|
| train_targets: np.ndarray,
|
| val_targets: np.ndarray,
|
| fold_idx: int,
|
| device: torch.device,
|
| config: Dict[str, Any],
|
| training_config: Dict[str, Any],
|
| save_dir: str = "oof_models",
|
| train_lab_indices: Optional[np.ndarray] = None,
|
| val_lab_indices: Optional[np.ndarray] = None,
|
| ) -> Tuple[FingerprintNN, np.ndarray, Dict[str, float]]:
|
| """Train Fingerprint Neural Network for one CV fold."""
|
|
|
| print(f" Training Fingerprint NN (Fold {fold_idx})...")
|
|
|
| model = FingerprintNN(**config)
|
| trainer = NeuralNetworkTrainer(
|
| model=model,
|
| device=device,
|
| lr=training_config["lr"],
|
| weight_decay=training_config["weight_decay"],
|
| )
|
|
|
| metrics, val_predictions = trainer.train_fold(
|
| train_features=train_fingerprints,
|
| train_targets=train_targets,
|
| val_features=val_fingerprints,
|
| val_targets=val_targets,
|
| epochs=training_config["epochs"],
|
| batch_size=training_config["batch_size"],
|
| patience=training_config["patience"],
|
| gradient_clip=training_config["gradient_clip"],
|
| warmup_epochs=training_config.get("warmup_epochs", 10),
|
| train_lab_indices=train_lab_indices,
|
| val_lab_indices=val_lab_indices,
|
| verbose=True,
|
| )
|
|
|
| _ensure_dir(save_dir)
|
| save_path = os.path.join(save_dir, f"fp_nn_fold_{fold_idx}.pt")
|
| trainer.save(save_path)
|
|
|
| print(
|
| f" Fingerprint NN Fold {fold_idx}: R² = {metrics['r2']:.4f}, " |
| f"MAE = {metrics['mae']:.4f}, Best Epoch = {metrics['best_epoch']}"
|
| )
|
|
|
| return model, val_predictions, metrics
|
|
|
|
|
| def train_gnn_fold(
|
| *,
|
| fold_train_graphs,
|
| fold_val_graphs,
|
| fold_train_lab,
|
| fold_val_lab,
|
| fold_train_targets,
|
| fold_val_targets,
|
| fold_idx: int,
|
| device: torch.device,
|
| config: Dict[str, Any],
|
| training_config: Dict[str, Any],
|
| save_dir: str = "oof_models",
|
| verbose_interval: int = 100,
|
| ):
|
| """Train a single GNN fold with warmup + cosine scheduling."""
|
|
|
| _ensure_dir(save_dir)
|
|
|
| target_mean = float(np.mean(fold_train_targets))
|
| target_std_raw = float(np.std(fold_train_targets))
|
| target_std = target_std_raw if target_std_raw > 1e-6 else 1.0
|
|
|
| scaled_train_targets = (fold_train_targets - target_mean) / target_std
|
| scaled_val_targets = (fold_val_targets - target_mean) / target_std
|
|
|
| for i, graph in enumerate(fold_train_graphs):
|
| graph.lab_feature = torch.tensor([fold_train_lab[i]], dtype=torch.long)
|
| graph.y = torch.tensor([scaled_train_targets[i]], dtype=torch.float32)
|
|
|
| for i, graph in enumerate(fold_val_graphs):
|
| graph.lab_feature = torch.tensor([fold_val_lab[i]], dtype=torch.long)
|
| graph.y = torch.tensor([scaled_val_targets[i]], dtype=torch.float32)
|
|
|
| model = GATModel(**config).to(device)
|
|
|
| lr = training_config.get("lr", 3e-4)
|
| weight_decay = training_config.get("weight_decay", 5e-6)
|
| optimizer = AdamW(
|
| model.parameters(),
|
| lr=lr,
|
| weight_decay=weight_decay,
|
| betas=training_config.get("betas", (0.9, 0.999)),
|
| )
|
|
|
| criterion = nn.MSELoss()
|
|
|
| epochs = training_config.get("epochs", 800)
|
| warmup_epochs = training_config.get("warmup_epochs", 0)
|
| min_lr = training_config.get("min_lr", 1e-6)
|
| factor = training_config.get("factor", 0.7)
|
|
|
| def lr_lambda(epoch: int) -> float:
|
| if warmup_epochs > 0 and epoch < warmup_epochs:
|
| return float(epoch + 1) / warmup_epochs
|
| total_decay_epochs = max(1, epochs - warmup_epochs)
|
| progress = max(0.0, epoch - warmup_epochs) / total_decay_epochs
|
| return 0.5 * (1.0 + math.cos(math.pi * progress))
|
|
|
| scheduler = LambdaLR(optimizer, lr_lambda)
|
| plateau_scheduler = ReduceLROnPlateau(
|
| optimizer,
|
| patience=training_config.get("plateau_patience", 30),
|
| factor=factor,
|
| min_lr=min_lr,
|
| )
|
|
|
| batch_size = training_config.get("batch_size", 32)
|
| gradient_clip = training_config.get("gradient_clip", 1.0)
|
|
|
| train_loader = DataLoader(
|
| fold_train_graphs,
|
| batch_size=batch_size,
|
| shuffle=True,
|
| drop_last=True,
|
| )
|
| val_loader = DataLoader(
|
| fold_val_graphs,
|
| batch_size=batch_size,
|
| shuffle=False,
|
| )
|
|
|
| best_val_r2 = -float("inf")
|
| patience = training_config.get("patience", 100)
|
| patience_counter = 0
|
| checkpoint_path = os.path.join(save_dir, f"gnn_fold_{fold_idx}.pt")
|
|
|
| for epoch in range(epochs):
|
| model.train()
|
| train_loss = 0.0
|
| train_batches = 0
|
|
|
| for batch in train_loader:
|
| batch = batch.to(device)
|
| optimizer.zero_grad()
|
| pred = model(
|
| batch.x,
|
| batch.edge_index,
|
| batch.batch,
|
| batch.lab_feature,
|
| getattr(batch, "edge_attr", None),
|
| )
|
| loss = criterion(pred, batch.y)
|
| loss.backward()
|
| torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=gradient_clip)
|
| optimizer.step()
|
|
|
| train_loss += loss.item()
|
| train_batches += 1
|
|
|
| if train_batches > 0:
|
| train_loss /= train_batches
|
|
|
| model.eval()
|
| val_preds_scaled: List[float] = []
|
| val_targets_scaled: List[float] = []
|
|
|
| with torch.no_grad():
|
| for batch in val_loader:
|
| batch = batch.to(device)
|
| pred = model(
|
| batch.x,
|
| batch.edge_index,
|
| batch.batch,
|
| batch.lab_feature,
|
| getattr(batch, "edge_attr", None),
|
| )
|
| preds_np = pred.cpu().numpy()
|
| targets_np = batch.y.cpu().numpy()
|
| val_preds_scaled.extend(preds_np.tolist())
|
| val_targets_scaled.extend(targets_np.tolist())
|
|
|
| val_preds = np.asarray(val_preds_scaled, dtype=np.float32) * target_std + target_mean
|
| val_targets = np.asarray(val_targets_scaled, dtype=np.float32) * target_std + target_mean
|
|
|
| val_r2 = r2_score(val_targets, val_preds)
|
| val_mae = mean_absolute_error(val_targets, val_preds)
|
|
|
| scheduler.step()
|
| plateau_scheduler.step(train_loss)
|
|
|
| if val_r2 > best_val_r2:
|
| best_val_r2 = val_r2
|
| patience_counter = 0
|
| torch.save(
|
| {
|
| "model_state": model.state_dict(),
|
| "target_mean": target_mean,
|
| "target_std": target_std,
|
| },
|
| checkpoint_path,
|
| )
|
| else:
|
| patience_counter += 1
|
|
|
| if verbose_interval and (epoch + 1) % verbose_interval == 0:
|
| current_lr = optimizer.param_groups[0]["lr"]
|
| print(
|
| f" Epoch {epoch + 1}: Train Loss={train_loss:.4f}, "
|
| f"Val R²={val_r2:.4f}, MAE={val_mae:.4f}, LR={current_lr:.2e}" |
| )
|
|
|
| if patience_counter >= patience:
|
| break
|
|
|
| if os.path.exists(checkpoint_path):
|
| checkpoint = torch.load(checkpoint_path, map_location=device)
|
| if isinstance(checkpoint, dict) and "model_state" in checkpoint:
|
| model.load_state_dict(checkpoint["model_state"])
|
| target_mean = float(checkpoint.get("target_mean", target_mean))
|
| target_std = float(checkpoint.get("target_std", target_std))
|
| else:
|
| model.load_state_dict(checkpoint)
|
| if target_std == 0:
|
| target_std = 1.0
|
|
|
| model.eval()
|
| final_val_preds_scaled: List[float] = []
|
| with torch.no_grad():
|
| for batch in val_loader:
|
| batch = batch.to(device)
|
| pred = model(
|
| batch.x,
|
| batch.edge_index,
|
| batch.batch,
|
| batch.lab_feature,
|
| getattr(batch, "edge_attr", None),
|
| )
|
| final_val_preds_scaled.extend(pred.cpu().numpy().tolist())
|
|
|
| final_val_preds = np.asarray(final_val_preds_scaled, dtype=np.float32) * target_std + target_mean
|
| final_r2 = r2_score(fold_val_targets, final_val_preds)
|
| final_mae = mean_absolute_error(fold_val_targets, final_val_preds)
|
|
|
| setattr(model, "target_mean", float(target_mean))
|
| setattr(model, "target_std", float(target_std))
|
|
|
| print(f" GNN Fold {fold_idx}: R² = {final_r2:.4f}, MAE = {final_mae:.4f}") |
|
|
| return model, final_val_preds.astype(np.float32), {"r2": final_r2, "mae": final_mae}
|
|
|
|
|
| __all__ = [
|
| "NeuralNetworkTrainer",
|
| "train_descriptor_nn_fold",
|
| "train_fingerprint_nn_fold",
|
| "train_gnn_fold",
|
| "train_hybrid_model_fold",
|
| ]
|
|
|
|
|
| def _prepare_hybrid_graphs(
|
| graphs,
|
| lab_indices: np.ndarray,
|
| targets: np.ndarray,
|
| descriptors: np.ndarray,
|
| ) -> None:
|
| for i, graph in enumerate(graphs):
|
| graph.lab_feature = torch.tensor([lab_indices[i]], dtype=torch.long)
|
| graph.y = torch.tensor([targets[i]], dtype=torch.float32)
|
| descriptor_tensor = torch.tensor(descriptors[i], dtype=torch.float32)
|
| if descriptor_tensor.dim() == 1:
|
| descriptor_tensor = descriptor_tensor.unsqueeze(0)
|
| graph.descriptors = descriptor_tensor
|
|
|
|
|
| def train_hybrid_model_fold(
|
| *,
|
| model_name: str,
|
| graph_model_class: Type[nn.Module],
|
| config: Dict[str, Any],
|
| training_config: Dict[str, Any],
|
| fold_train_graphs,
|
| fold_val_graphs,
|
| fold_train_lab,
|
| fold_val_lab,
|
| fold_train_targets,
|
| fold_val_targets,
|
| fold_train_descriptors,
|
| fold_val_descriptors,
|
| fold_idx: int,
|
| device: torch.device,
|
| save_dir: str = "hybrid_models",
|
| verbose_interval: int = 50,
|
| ):
|
| """Train a Hybrid GNN model (graph + descriptors) for one CV fold."""
|
|
|
| _ensure_dir(save_dir)
|
|
|
| target_mean = float(np.mean(fold_train_targets))
|
| target_std_raw = float(np.std(fold_train_targets))
|
| target_std = target_std_raw if target_std_raw > 1e-6 else 1.0
|
|
|
| scaled_train_targets = (fold_train_targets - target_mean) / target_std
|
| scaled_val_targets = (fold_val_targets - target_mean) / target_std
|
|
|
| fold_train_descriptors = np.asarray(fold_train_descriptors, dtype=np.float32)
|
| fold_val_descriptors = np.asarray(fold_val_descriptors, dtype=np.float32)
|
|
|
| _prepare_hybrid_graphs(
|
| fold_train_graphs,
|
| fold_train_lab,
|
| scaled_train_targets,
|
| fold_train_descriptors,
|
| )
|
| _prepare_hybrid_graphs(
|
| fold_val_graphs,
|
| fold_val_lab,
|
| scaled_val_targets,
|
| fold_val_descriptors,
|
| )
|
|
|
| graph_model_kwargs = dict(config.get("graph_model_kwargs", {}))
|
| graph_feature_dim = config.get("graph_feature_dim")
|
|
|
| model = HybridModel(
|
| graph_model_class=graph_model_class,
|
| descriptor_dim=fold_train_descriptors.shape[1],
|
| graph_model_kwargs=graph_model_kwargs,
|
| graph_feature_dim=graph_feature_dim,
|
| descriptor_hidden_dims=config.get("descriptor_hidden_dims"),
|
| final_hidden_dims=config.get("final_hidden_dims"),
|
| dropout=config.get("dropout", 0.2),
|
| use_batch_norm=config.get("use_batch_norm", True),
|
| output_dim=config.get("output_dim", 1),
|
| ).to(device)
|
|
|
| lr = training_config.get("lr", 3e-4)
|
| weight_decay = training_config.get("weight_decay", 1e-5)
|
| optimizer = AdamW(
|
| model.parameters(),
|
| lr=lr,
|
| weight_decay=weight_decay,
|
| betas=training_config.get("betas", (0.9, 0.999)),
|
| )
|
|
|
| criterion = nn.MSELoss()
|
|
|
| epochs = training_config.get("epochs", 400)
|
| patience = training_config.get("patience", 80)
|
| gradient_clip = training_config.get("gradient_clip", 1.0)
|
|
|
| plateau_scheduler = ReduceLROnPlateau(
|
| optimizer,
|
| patience=training_config.get("plateau_patience", 30),
|
| factor=training_config.get("factor", 0.7),
|
| min_lr=training_config.get("min_lr", 1e-6),
|
| )
|
|
|
| batch_size = training_config.get("batch_size", 32)
|
| train_loader = DataLoader(
|
| fold_train_graphs,
|
| batch_size=batch_size,
|
| shuffle=True,
|
| drop_last=len(fold_train_graphs) > batch_size,
|
| )
|
| val_loader = DataLoader(
|
| fold_val_graphs,
|
| batch_size=batch_size,
|
| shuffle=False,
|
| )
|
|
|
| best_state: Dict[str, Any] | None = None
|
| best_r2 = -float("inf")
|
| best_mae = float("inf")
|
| patience_counter = 0
|
|
|
| for epoch in range(epochs):
|
| model.train()
|
| cumulative_loss = 0.0
|
| batch_count = 0
|
|
|
| for batch in train_loader:
|
| batch = batch.to(device)
|
| optimizer.zero_grad()
|
|
|
| lab_tensor = batch.lab_feature.squeeze(-1) if batch.lab_feature.dim() > 1 else batch.lab_feature
|
| descriptors_tensor = batch.descriptors.reshape(batch.num_graphs, -1)
|
| preds = model(
|
| batch.x,
|
| batch.edge_index,
|
| batch.batch,
|
| lab_tensor,
|
| descriptors_tensor,
|
| getattr(batch, "edge_attr", None),
|
| )
|
| target_tensor = batch.y.view(-1)
|
| loss = criterion(preds, target_tensor)
|
| loss.backward()
|
| torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=gradient_clip)
|
| optimizer.step()
|
|
|
| cumulative_loss += loss.item()
|
| batch_count += 1
|
|
|
| avg_train_loss = cumulative_loss / max(batch_count, 1)
|
|
|
| model.eval()
|
| val_preds: List[float] = []
|
| val_targets_list: List[float] = []
|
| val_loss = 0.0
|
| val_batches = 0
|
|
|
| with torch.no_grad():
|
| for batch in val_loader:
|
| batch = batch.to(device)
|
| lab_tensor = batch.lab_feature.squeeze(-1) if batch.lab_feature.dim() > 1 else batch.lab_feature
|
| descriptors_tensor = batch.descriptors.reshape(batch.num_graphs, -1)
|
| preds = model(
|
| batch.x,
|
| batch.edge_index,
|
| batch.batch,
|
| lab_tensor,
|
| descriptors_tensor,
|
| getattr(batch, "edge_attr", None),
|
| )
|
| target_tensor = batch.y.view(-1)
|
| val_loss += criterion(preds, target_tensor).item()
|
| val_batches += 1
|
| preds_np = preds.cpu().numpy()
|
| targets_np = target_tensor.cpu().numpy()
|
| val_preds.extend((preds_np * target_std + target_mean).tolist())
|
| val_targets_list.extend((targets_np * target_std + target_mean).tolist())
|
|
|
| avg_val_loss = val_loss / max(val_batches, 1)
|
| plateau_scheduler.step(avg_val_loss)
|
|
|
| current_r2 = r2_score(val_targets_list, val_preds)
|
| current_mae = mean_absolute_error(val_targets_list, val_preds)
|
|
|
| improved = current_r2 > best_r2 + 1e-5
|
| if improved:
|
| best_r2 = current_r2
|
| best_mae = current_mae
|
| patience_counter = 0
|
| best_state = {
|
| "state_dict": model.state_dict(),
|
| "epoch": epoch + 1,
|
| "train_loss": avg_train_loss,
|
| }
|
| torch.save(
|
| {
|
| "model_state": best_state["state_dict"],
|
| "config": config,
|
| "training_config": training_config,
|
| "target_mean": target_mean,
|
| "target_std": target_std,
|
| },
|
| os.path.join(save_dir, f"{model_name}_fold_{fold_idx}.pt"),
|
| )
|
| else:
|
| patience_counter += 1
|
|
|
| if verbose_interval and (epoch + 1) % verbose_interval == 0:
|
| current_lr = optimizer.param_groups[0]["lr"]
|
| print(
|
| f" [{model_name}] Epoch {epoch+1}: TrainLoss={avg_train_loss:.4f} "
|
| f"ValLoss={avg_val_loss:.4f} ValR²={current_r2:.4f} ValMAE={current_mae:.4f} LR={current_lr:.2e}" |
| f"{' *' if improved else ''}"
|
| )
|
|
|
| if patience_counter >= patience:
|
| break
|
|
|
| if best_state is None:
|
| raise RuntimeError(f"{model_name} fold {fold_idx} failed to improve during training.")
|
|
|
| model.load_state_dict(best_state["state_dict"])
|
| model.eval()
|
|
|
| final_val_preds: List[float] = []
|
| with torch.no_grad():
|
| for batch in val_loader:
|
| batch = batch.to(device)
|
| lab_tensor = batch.lab_feature.squeeze(-1) if batch.lab_feature.dim() > 1 else batch.lab_feature
|
| descriptors_tensor = batch.descriptors.reshape(batch.num_graphs, -1)
|
| preds = model(
|
| batch.x,
|
| batch.edge_index,
|
| batch.batch,
|
| lab_tensor,
|
| descriptors_tensor,
|
| getattr(batch, "edge_attr", None),
|
| )
|
| preds_np = preds.cpu().numpy()
|
| final_val_preds.extend((preds_np * target_std + target_mean).tolist())
|
|
|
| final_val_preds_array = np.asarray(final_val_preds, dtype=np.float32)
|
|
|
| metrics = {"r2": best_r2, "mae": best_mae, "best_epoch": best_state["epoch"]}
|
|
|
| setattr(model, "target_mean", float(target_mean))
|
| setattr(model, "target_std", float(target_std))
|
|
|
| print(
|
| f" {model_name} Fold {fold_idx}: R² = {best_r2:.4f}, MAE = {best_mae:.4f}, " |
| f"Best Epoch = {best_state['epoch']}"
|
| )
|
|
|
| return model, final_val_preds_array, metrics
|
|
|