"""GPyTorch-based Gaussian Process models with physics-informed priors.""" from typing import Callable, Optional, Tuple import torch from torch import Tensor import gpytorch from gpytorch.models import ExactGP from gpytorch.means import ConstantMean, ZeroMean from gpytorch.kernels import ScaleKernel, RBFKernel, MaternKernel from gpytorch.likelihoods import GaussianLikelihood from gpytorch.distributions import MultivariateNormal from gpytorch.mlls import ExactMarginalLogLikelihood from botorch.models.gpytorch import GPyTorchModel from botorch.posteriors.gpytorch import GPyTorchPosterior from botorch.models.transforms.input import Normalize from botorch.models.transforms.outcome import Standardize from physics_informed_bo.models.base import SurrogateModel from physics_informed_bo.models.physics_model import PhysicsMeanFunction class _ExactGPModel(ExactGP, GPyTorchModel): """Core GPyTorch ExactGP model with BoTorch compatibility.""" _num_outputs = 1 def __init__( self, train_X: Tensor, train_y: Tensor, likelihood: GaussianLikelihood, mean_module: Optional[gpytorch.means.Mean] = None, kernel: str = "matern", ard_num_dims: Optional[int] = None, ): super().__init__(train_X, train_y.squeeze(-1), likelihood) self.mean_module = mean_module or ConstantMean() if kernel == "rbf": base_kernel = RBFKernel(ard_num_dims=ard_num_dims) elif kernel == "matern": base_kernel = MaternKernel(nu=2.5, ard_num_dims=ard_num_dims) else: raise ValueError(f"Unknown kernel: {kernel}. Use 'rbf' or 'matern'.") self.covar_module = ScaleKernel(base_kernel) def forward(self, X: Tensor) -> MultivariateNormal: mean = self.mean_module(X) covar = self.covar_module(X) return MultivariateNormal(mean, covar) class StandardGP(SurrogateModel): """Standard Gaussian Process model (no physics, pure data-driven). Uses GPyTorch for the GP and is BoTorch-compatible for optimization. """ def __init__( self, kernel: str = "matern", noise_variance: float = 0.01, learn_noise: bool = True, normalize_inputs: bool = True, standardize_outputs: bool = True, device: str = "cpu", dtype: torch.dtype = torch.float64, ): self.kernel = kernel self.noise_variance = noise_variance self.learn_noise = learn_noise self.normalize_inputs = normalize_inputs self.standardize_outputs = standardize_outputs self.device = torch.device(device) self.dtype = dtype self._model = None self._likelihood = None def fit( self, X: Tensor, y: Tensor, training_iterations: int = 100, lr: float = 0.1, ) -> None: """Fit the GP model by optimizing the marginal log likelihood.""" X = X.to(device=self.device, dtype=self.dtype) y = y.to(device=self.device, dtype=self.dtype) if y.dim() == 1: y = y.unsqueeze(-1) self._likelihood = GaussianLikelihood() if not self.learn_noise: self._likelihood.noise = self.noise_variance self._likelihood.noise_covar.raw_noise.requires_grad_(False) self._model = _ExactGPModel( train_X=X, train_y=y, likelihood=self._likelihood, kernel=self.kernel, ard_num_dims=X.shape[-1], ).to(device=self.device, dtype=self.dtype) self._optimize_hyperparameters(X, y, training_iterations, lr) def _optimize_hyperparameters( self, X: Tensor, y: Tensor, n_iter: int, lr: float ) -> None: """Optimize GP hyperparameters via type-II MLE.""" self._model.train() self._likelihood.train() optimizer = torch.optim.Adam(self._model.parameters(), lr=lr) mll = ExactMarginalLogLikelihood(self._likelihood, self._model) for _ in range(n_iter): optimizer.zero_grad() output = self._model(X) loss = -mll(output, y.squeeze(-1)) loss.backward() optimizer.step() self._model.eval() self._likelihood.eval() def predict(self, X: Tensor) -> Tuple[Tensor, Tensor]: X = X.to(device=self.device, dtype=self.dtype) self._model.eval() self._likelihood.eval() with torch.no_grad(), gpytorch.settings.fast_pred_var(): posterior = self._likelihood(self._model(X)) mean = posterior.mean.unsqueeze(-1) variance = posterior.variance.unsqueeze(-1) return mean, variance def posterior(self, X: Tensor): self._model.eval() self._likelihood.eval() return self._model.posterior(X) @property def model(self): """Access the underlying BoTorch-compatible GP model.""" return self._model class PhysicsInformedGP(SurrogateModel): """GP with a physics model as the mean function. The GP prior mean is set to the physics model predictions, so the GP learns the residual (discrepancy) between the physics model and reality. This is the core model of the platform. Architecture: f(x) = physics(x) + GP_residual(x) where GP_residual ~ GP(0, k(x, x')) """ def __init__( self, physics_fn: Callable[[Tensor], Tensor], kernel: str = "matern", physics_output_scale: float = 1.0, learnable_physics_scale: bool = True, noise_variance: float = 0.01, learn_noise: bool = True, device: str = "cpu", dtype: torch.dtype = torch.float64, ): self.physics_fn = physics_fn self.kernel = kernel self.physics_output_scale = physics_output_scale self.learnable_physics_scale = learnable_physics_scale self.noise_variance = noise_variance self.learn_noise = learn_noise self.device = torch.device(device) self.dtype = dtype self._model = None self._likelihood = None def fit( self, X: Tensor, y: Tensor, training_iterations: int = 200, lr: float = 0.05, ) -> None: """Fit the physics-informed GP model.""" X = X.to(device=self.device, dtype=self.dtype) y = y.to(device=self.device, dtype=self.dtype) if y.dim() == 1: y = y.unsqueeze(-1) self._likelihood = GaussianLikelihood() if not self.learn_noise: self._likelihood.noise = self.noise_variance self._likelihood.noise_covar.raw_noise.requires_grad_(False) physics_mean = PhysicsMeanFunction( physics_fn=self.physics_fn, output_scale=self.physics_output_scale, learnable_scale=self.learnable_physics_scale, ) self._model = _ExactGPModel( train_X=X, train_y=y, likelihood=self._likelihood, mean_module=physics_mean, kernel=self.kernel, ard_num_dims=X.shape[-1], ).to(device=self.device, dtype=self.dtype) self._optimize_hyperparameters(X, y, training_iterations, lr) def _optimize_hyperparameters( self, X: Tensor, y: Tensor, n_iter: int, lr: float ) -> None: self._model.train() self._likelihood.train() optimizer = torch.optim.Adam(self._model.parameters(), lr=lr) mll = ExactMarginalLogLikelihood(self._likelihood, self._model) for _ in range(n_iter): optimizer.zero_grad() output = self._model(X) loss = -mll(output, y.squeeze(-1)) loss.backward() optimizer.step() self._model.eval() self._likelihood.eval() def predict(self, X: Tensor) -> Tuple[Tensor, Tensor]: X = X.to(device=self.device, dtype=self.dtype) self._model.eval() self._likelihood.eval() with torch.no_grad(), gpytorch.settings.fast_pred_var(): posterior = self._likelihood(self._model(X)) mean = posterior.mean.unsqueeze(-1) variance = posterior.variance.unsqueeze(-1) return mean, variance def posterior(self, X: Tensor): self._model.eval() self._likelihood.eval() return self._model.posterior(X) @property def model(self): return self._model def get_residuals(self, X: Tensor, y: Tensor) -> Tensor: """Compute residuals between physics predictions and observations.""" with torch.no_grad(): physics_pred = self.physics_fn(X) return y.squeeze() - physics_pred