from __future__ import annotations import math from collections.abc import Mapping, Sequence import torch import torch.nn as nn class FCN(nn.Module): """Fully connected network used by the BPINN solution model.""" def __init__( self, layer_sizes: Sequence[int], activation: str = "tanh", dtype: torch.dtype = torch.float64, stddev: float | None = None, ) -> None: super().__init__() if len(layer_sizes) < 2: raise ValueError("layer_sizes must include input and output widths") if activation not in {"tanh", "sin", "sine", "relu", "gelu"}: raise ValueError(f"unsupported activation: {activation}") if stddev is None: stddev = math.sqrt(50.0 / layer_sizes[1]) if stddev <= 0: raise ValueError("stddev must be positive") self.activation = "sin" if activation == "sine" else activation self.linears = nn.ModuleList( nn.Linear(layer_sizes[index], layer_sizes[index + 1], dtype=dtype) for index in range(len(layer_sizes) - 1) ) for linear in self.linears: nn.init.trunc_normal_( linear.weight, mean=0.0, std=stddev, a=-2.0 * stddev, b=2.0 * stddev, ) nn.init.zeros_(linear.bias) def _activate(self, values: torch.Tensor) -> torch.Tensor: if self.activation == "gelu": return torch.nn.functional.gelu(values) return getattr(torch, self.activation)(values) def forward(self, inputs: torch.Tensor) -> torch.Tensor: hidden = inputs for linear in self.linears[:-1]: hidden = self._activate(linear(hidden)) return self.linears[-1](hidden) class PhysNet(nn.Module): """Physics-informed network with solution and optional parameter outputs.""" def __init__( self, input_dim: int = 1, n_layers: int = 4, n_neurons: int = 50, n_out_sol: int = 1, n_out_par: int = 0, activation: str = "tanh", dtype: torch.dtype = torch.float64, ) -> None: super().__init__() if min(input_dim, n_layers, n_neurons, n_out_sol) <= 0 or n_out_par < 0: raise ValueError("network dimensions must be positive") self.n_out_sol = n_out_sol self.n_out_par = n_out_par output_dim = n_out_sol + n_out_par self.fcn = FCN( [input_dim, *([n_neurons] * n_layers), output_dim], activation=activation, dtype=dtype, ) def forward(self, inputs: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor | None]: outputs = self.fcn(inputs) solution = outputs[..., : self.n_out_sol] parameters = outputs[..., self.n_out_sol :] if self.n_out_par else None return solution, parameters def predict_u(self, inputs: torch.Tensor) -> torch.Tensor: return self.forward(inputs)[0] class BPINN(nn.Module): """Physics-informed neural network used by the BPINNs workflow.""" def __init__( self, input_dim: int = 1, n_layers: int = 4, n_neurons: int = 50, n_out_sol: int = 1, n_out_par: int = 0, activation: str = "tanh", dtype: torch.dtype = torch.float64, ) -> None: super().__init__() self.net = PhysNet( input_dim=input_dim, n_layers=n_layers, n_neurons=n_neurons, n_out_sol=n_out_sol, n_out_par=n_out_par, activation=activation, dtype=dtype, ) def forward(self, inputs: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor | None]: return self.net(inputs) def predict_u(self, inputs: torch.Tensor) -> torch.Tensor: return self.net.predict_u(inputs) def build_model(config: Mapping, dtype: torch.dtype = torch.float64) -> BPINN: return BPINN( input_dim=int(config["input_dim"]), n_layers=int(config["n_layers"]), n_neurons=int(config["n_neurons"]), n_out_sol=int(config["n_out_sol"]), n_out_par=int(config["n_out_par"]), activation=str(config["activation"]), dtype=dtype, ) def laplace1d_loss_components( model: BPINN, x_solution: torch.Tensor, u_solution: torch.Tensor, x_boundary: torch.Tensor, u_boundary: torch.Tensor, x_pde: torch.Tensor, ) -> dict[str, torch.Tensor]: solution_prediction = model.predict_u(x_solution) boundary_prediction = model.predict_u(x_boundary) pde_points = x_pde.detach().requires_grad_(True) pde_prediction = model.predict_u(pde_points) first_derivative = torch.autograd.grad( pde_prediction, pde_points, torch.ones_like(pde_prediction), create_graph=True, )[0] second_derivative = torch.autograd.grad( first_derivative, pde_points, torch.ones_like(first_derivative), create_graph=True, )[0] source = torch.pi**2 * torch.sin(torch.pi * pde_points) residual = second_derivative + source return { "data": torch.mean((solution_prediction - u_solution).square()), "boundary": torch.mean((boundary_prediction - u_boundary).square()), "pde": torch.mean(residual.square()), } def weighted_loss(components: Mapping[str, torch.Tensor], weights: Mapping) -> torch.Tensor: return ( float(weights["data"]) * components["data"] + float(weights["boundary"]) * components["boundary"] + float(weights["pde"]) * components["pde"] ) def posterior_predict( model: BPINN, states: Sequence[Mapping[str, torch.Tensor]], inputs: torch.Tensor, ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: """Compute predictive mean and standard deviation from parameter-state samples.""" if not states: raise ValueError("posterior prediction requires at least one parameter state") original_state = { key: value.detach().clone() for key, value in model.state_dict().items() } predictions = [] try: for state in states: model.load_state_dict(state, strict=True) with torch.no_grad(): predictions.append(model.predict_u(inputs)) finally: model.load_state_dict(original_state, strict=True) samples = torch.stack(predictions) return samples.mean(dim=0), samples.std(dim=0, unbiased=False), samples