Source code for pihnn.nn

"""Definition of PIHNN networks."""

import torch
import math, warnings
import numpy as np
import pihnn.utils as utils
torch.set_default_dtype(torch.float64) # This also implies default complex128. It applies to all scripts of the library
[docs] device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') # Shared with other scripts of the library
class ComplexLinear(torch.nn.Module): """ Extension of :class:`torch.nn.Linear` to complex values. So, a complex linear layer performs the operation .. math:: y = Wx + b, where the input :math:`x` is a complex vector of dimension in_features, the output :math:`y` and the bias :math:`b` are complex vectors of dimension out_features, and the complex-valued weight tensor :math:`W` has dimensions (in_features,out_features). """ def __init__(self, stack_features, in_features, out_features, n_domains=None, has_bias=True): """ :param in_features: Number of input units at the current layer. :type in_features: int :param out_features: Number of output units at the current layer. :type out_features: int :param has_bias: True if the current layer includes the bias term. :type has_bias: bool """ super(ComplexLinear, self).__init__() self.is_dd = (n_domains is not None) self.has_bias = has_bias self.in_features = in_features if(self.is_dd): self.W = torch.nn.Parameter(torch.empty((stack_features, n_domains, out_features, in_features), dtype=torch.complex128)) else: self.W = torch.nn.Parameter(torch.empty((stack_features, out_features, in_features), dtype=torch.complex128)) if(self.has_bias): if(self.is_dd): self.B = torch.nn.Parameter(torch.empty((stack_features, n_domains, out_features, 1), dtype=torch.complex128)) else: self.B = torch.nn.Parameter(torch.empty((stack_features, out_features, 1), dtype=torch.complex128)) def init(self, scaling): """ Re-initialization of weights and bias. Initialization is defined as a scaled complex-valued He initialization (`Trabelsi [2018] <https://arxiv.org/abs/1705.09792>`_): .. math:: \\text{Re}(w),\\text{Im}(w) &\sim \mathcal{N}\left(0,\\frac{\\texttt{scaling}}{2 \\texttt{in_features}}\\right), \\\\ bias &=0. This allows us to easily include the initialization strategy from `Calafà et al. [2024] <https://doi.org/10.1016/j.cma.2024.117406>`_, Section 3.2.4. :param scaling: Scaling in the He initialization. :type scaling: float """ torch.nn.init.normal_(self.W, 0., math.sqrt(scaling)/math.sqrt(2*self.in_features)) if (self.has_bias): torch.nn.init.constant_(self.B, 0.) def weight(self): """ :returns: **weight** (:class:`torch.tensor`) - Tensor with weights. """ return self.W def bias(self): """ :returns: **bias** (:class:`torch.tensor`) - Bias vector. """ return self.B def forward(self, input): """ Forward step :math:`y=Wx+b`. :param input: The input vector :math:`x`. :type input: :class:`torch.tensor` :returns: **output** (:class:`torch.tensor`) - The output vector :math:`y`. """ if len(input.shape)==1 and not self.is_dd and self.W.shape[2]==1: # First layer in PIHNN networks if(self.has_bias): return torch.einsum('abc,d->abd', self.W, input) + self.B else: return torch.einsum('abc,d->abd', self.W, input) elif len(input.shape)==2 and self.is_dd and self.W.shape[3]==1: # First layer in DD_PIHNN network if(self.has_bias): return torch.einsum('abcd,be->abce', self.W, input) + self.B else: return torch.einsum('abcd,be->abce', self.W, input) elif len(input.shape)==3 and not self.is_dd: # All layers in PIHNN networks if(self.has_bias): return torch.einsum('abc,acd->abd', self.W, input) + self.B else: return torch.einsum('abc,acd->abd', self.W, input) elif len(input.shape)==4 and self.is_dd: # All layers in DD_PIHNN networks if(self.has_bias): return torch.einsum('abcd,abde->abce', self.W, input) + self.B else: return torch.einsum('abcd,abde->abce', self.W, input) else: raise ValueError("Sizes of input and weight tensors do not coincide.") class ComplexParameter(torch.nn.Module): """ Extension of :class:`torch.nn.Parameter` to complex values. """ def __init__(self, minvalue=-1-1j, maxvalue=1+1j, in_features=1, out_features=1): """ :param minvalue: The minimum value in the parameter initialization. Specifically, parameters are initialized to :math:`p \sim \mathcal{U}(-\\texttt{minvalue},\\texttt{maxvalue})`. :type minvalue: complex :param maxvalue: The minimum value in the parameter initialization. See above. :type maxvalue: complex :param in_features: First size of parameter tensor. :type in_features: int :param out_features: Second size of parameter tensor. :type out_features: int """ super(ComplexParameter, self).__init__() self.p_r = torch.nn.Parameter((maxvalue.real-minvalue.real) * torch.rand(in_features, out_features) + minvalue.real) self.p_i = torch.nn.Parameter((maxvalue.imag-minvalue.imag) * torch.rand(in_features, out_features) + minvalue.imag) def forward(self): """ It returns the parameter tensor. :returns: **parameter** (:class:`torch.tensor`) - The tensor of size (in_features,out_features) with the parameters. """ return self.p_r + 1.j * self.p_i class PIHNN(torch.nn.Module): """ Main class for the employment of physics-informed holomorphic neural networks (PIHNNs). PIHNNs are able to solve 4 types of problems, where :math:`\\varphi,\psi` denote the holomorphic output(s) of the network: * 2D Laplace problem ('laplace'): .. math:: \\nabla^2u=0 \\Leftrightarrow u=\\text{Re}(\\varphi). * 2D biharmonic problem with Goursat representation ('biharmonic'): .. math:: \\nabla^4u=0 \\Leftrightarrow u=\\text{Re}((x-iy)\\varphi + \psi). * 2D linear elasticity with Kolosov-Muskhelishvili representation ('km'): :math:`\sigma_{xx},\sigma_{yy},\sigma_{xy},u_x,u_y` solve the 2D linear elasticity problem :math:`\\Leftrightarrow` .. math:: \\begin{cases} \sigma_{xx} + \sigma_{yy} = 4 \\text{Re}(\\varphi'), \\\\ \sigma_{yy} - \sigma_{xx} + 2i\sigma_{xy} = (\overline{z}\\varphi''+\psi'), \\\\ 2\mu(u_x + iu_y) = \gamma \\varphi - z \overline{\\varphi'} - \overline{\psi}, \end{cases} where :math:`\\mu` is the shear modulus and :math:`\gamma` is the Kolosov constant. * 2D linear elasticity with Kolosov-Muskhelishvili representation, stress-only ('km-so'): :math:`\sigma_{xx},\sigma_{yy},\sigma_{xy}` solve the 2D linear elasticity problem :math:`\\Leftrightarrow` .. math:: \\begin{cases} \sigma_{xx} + \sigma_{yy} = 4 \\text{Re}(\\varphi), \\\\ \sigma_{yy} - \sigma_{xx} + 2i\sigma_{xy} = (\overline{z}\\varphi'+\psi). \end{cases} The output of the network is therefore the scalar function :math:`\\varphi_{NN}\\approx\\varphi` in the Laplace problem. Instead, for the other problems the PIHNN is composed by 2 stacked networks :math:`\\varphi_{NN},\psi_{NN}\\approx\\varphi,\psi`. """ def __init__(self, PDE, units, material={"lambda": 1, "mu": 1}, activation=torch.exp, has_bias=True): """ :param PDE: Problem to solve, either 'laplace', 'biharmonic', 'km' or 'km-so'. :type PDE: str :param units: List containing number of units at each layer, e.g., [1,10,10,1]. :type units: list of int :param material: Properties of the material, dictionary with 'lambda' (first Lamé coefficient), 'mu' (second Lamé coefficient). :type material: dict :param activation: Activation function, by default the complex exponential. :type activation: callable :param has_bias: True if the linear layers include bias vectors. :type has_bias: bool """ super(PIHNN, self).__init__() self.PDE = PDE if (PDE == 'laplace'): self.n_outputs = 1 elif (PDE in ['biharmonic', 'km', 'km-so']): self.n_outputs = 2 else: raise ValueError("'PDE' must be either 'laplace', 'biharmonic', 'km' or 'km-so'.") self.n_layers = len(units) - 1 if (PDE in ['km', 'km-so']): self.material = material self.material["poisson"] = material["lambda"]/(2*(material["lambda"]+material["mu"])) self.material["young"] = material["mu"]*(3*material["lambda"]+2*material["mu"])/(material["lambda"]+material["mu"]) self.material["bulk"] = (3*material["lambda"]+2*material["mu"])/3 self.material["km_gamma"] = (material["lambda"]+3*material["mu"])/(material["lambda"] + material["mu"]) self.material["km_eta"] = -(1-2*self.material["poisson"])/(2*(1-self.material["poisson"])) self.material["km_theta"] = -1/(1-2*self.material["poisson"]) self.activation = activation self.layers = torch.nn.ModuleList() if (PDE == 'km-so'): warnings.warn("You are using the 'stress-only' configuration. Please ensure that boundary conditions are only of stress type.") for i in range(self.n_layers): self.layers.append(ComplexLinear(self.n_outputs, units[i], units[i+1], has_bias=has_bias)) def forward(self, z, real_output=False): """ Forward step, i.e., compute for :math:`j=1,2`: .. math:: \mathcal{L}_{N,j} \circ \phi \circ \mathcal{L}_{N-1,j} \circ \phi \dots \circ \mathcal{L}_{1,j} (z) where :math:`z` is the input, :math:`\phi` the activation function and :math:`\{\mathcal{L}_{i,j}\}` the complex linear layers (:class:`pihnn.nn.ComplexLinear`) for each layer :math:`i` and stacked network :math:`j`. :param z: Input of the network, typically a batch of coordinates from the domain boundary. :type z: :class:`torch.tensor` :param real_output: Whether to provide the output in the real-valued representation. :type real_output: bool :returns: **phi** (:class:`torch.tensor`) - Output of the network. As mentioned above, it has the same shape of the input for the Laplace problem but double size for the other problems. """ phi = self.layers[0](z) for i in range(1, self.n_layers): phi = self.layers[i](self.activation(phi)) if real_output: return self.apply_real_transformation(z, phi.squeeze(1)) else: return phi.squeeze(1) def initialize_weights(self, method, beta=0.5, sample=None, gauss=None): """ Initialization of PIHNNs. Implemented methods: * Complex-valued He initialization (`Trabelsi [2018] <https://arxiv.org/abs/1705.09792>`_): :math:`\\text{Re}(w),\\text{Im}(w)\sim \mathcal{N}\left(0,\\frac{1}{2 \\texttt{in_features}}\\right), \hspace{3mm} bias=0`. * Scaled complex-valued He initialization: :math:`\\text{Re}(w),\\text{Im}(w)\sim \mathcal{N}\left(0,\\frac{\\texttt{scaling}}{2 \\texttt{in_features}}\\right), \hspace{3mm} bias=0`. * PIHNNs ad-hoc initialization with exponential activations: See `Calafà et al. [2024] <https://doi.org/10.1016/j.cma.2024.117406>`_, Section 3.2.4. :param method: Either 'he', 'he_scaled', 'exp', see description above. :type method: str :param beta: Scaling coefficient in the scaled He initialization, :math:`\\beta` coefficient in the Calafà initialization, not used in He initialization. :type beta: float :param sample: Initial sample :math:`x_0` in the Calafà initialization, not used in the other methods. :type sample: :class:`torch.tensor` :param gauss: :math:`M_e` coefficient in the Calafà initialization, not used in the other methods. :type gauss: int """ if (method=='exp'): if(gauss==None): gauss = self.n_layers for i in range(self.n_layers): if (i<gauss): scaling = beta/torch.mean(torch.pow(torch.abs(sample),2)).detach() else: scaling = beta/math.exp(beta) self.layers[i].init(scaling) y = self.layers[i](sample) sample = self.activation(y) return if(method=='he'): scaling = 1 elif(method=='he_scaled'): scaling = beta else: raise ValueError("'method' must be either 'exp','he' or 'he_scaled'.") for i in range(self.n_layers): self.layers[i].init(scaling) def apply_real_transformation(self, z, phi): """ Based on the type of PDE, this method returns the real-valued output from the holomorphic potentials. We address to the documentation of :class:`pihnn.nn.PIHNN` for the review of the 4 types of problems and their associated representation. For PDE = 'laplace' and 'biharmonic', :math:`u` is evaluated at :math:`z`. For PDE = 'km' and 'km-so', :math:`\sigma_{xx},\sigma_{yy},\sigma_{xy},u_x,u_y` are stacked in a single tensor. Finally, in 'km-so', :math:`u_x,u_y` are identically zero. :params z: Input of the model, typically a batch of coordinates from the domain boundary. :type z: :class:`torch.tensor` :param phi: Complex-valued output of the network. :type phi: :class:`torch.tensor` :returns: **vars** (:class:`torch.tensor`) - Tensor containing the real-valued variable(s) evaluated at :math:`z`. """ match self.PDE: case 'laplace': return torch.real(phi)[0] case 'biharmonic': return torch.real(torch.conj(z)*phi[0] + phi[1]) # Goursat representation of biharmonic functions case 'km' | 'km-so': phi, psi = phi # The original "phi" actually includes both potentials if (self.PDE=='km'): # Normal configuration phi_z = utils.derivative(phi,z,holom=True) psi_z = utils.derivative(psi,z,holom=True) tmp = self.material["km_gamma"] * phi - z * torch.conj(phi_z) - torch.conj(psi) u_x = torch.real(tmp) / (2*self.material["mu"]) u_y = torch.imag(tmp) / (2*self.material["mu"]) elif (self.PDE=='km-so'): # Stress-only configuration phi_z = phi psi_z = psi u_x = 0.*torch.abs(phi_z) u_y = 0.*torch.abs(psi_z) else: raise ValueError("'model.PDE' must be either 'km' or 'km-so' for calculating stresses.") phi_zz = utils.derivative(phi_z,z,holom=True) tmp1 = 2*torch.real(phi_z) tmp2 = torch.conj(z)*phi_zz + psi_z sig_xx = tmp1 - torch.real(tmp2) sig_yy = tmp1 + torch.real(tmp2) sig_xy = torch.imag(tmp2) return torch.stack([sig_xx,sig_yy,sig_xy,u_x,u_y],0) case _: raise ValueError("model.PDE must be either 'laplace', 'biharmonic', 'km', 'km-so'.") class DD_PIHNN(PIHNN): """ Domain-decomposition physics-informed holomorphic neural networks (DD-PIHNNs). DD-PIHNNs have been introduced in `Calafà et al. [2024] <https://doi.org/10.1016/j.cma.2024.117406>`_, Section 4.3, to solve problems on multiply-connected domains. The structure is similar to :class:`pihnn.nn.PIHNN` but includes multiple stacked networks, each one corresponding to each function :math:`\\varphi,\psi` and each domain. """ def __init__(self, PDE, units, boundary, material={"lambda": 1, "mu": 1}, activation=torch.exp, has_bias=True): """ :param PDE: Problem to solve, either 'laplace', 'biharmonic', 'km' or 'km-so'. :type PDE: str :param units: List containing number of units at each layer, e.g., [1,10,10,1]. :type units: list of int :param boundary: Geometry of the domain, necessary for information regarding domain splitting. :type boundary: :class:`pihnn.geometries.boundary` :param material: Properties of the material, dictionary with 'lambda' (first Lamé coefficient), 'mu' (second Lamé coefficient). :type material: dict :param activation: Activation function, by default the complex exponential. :type activation: callable :param has_bias: True if the linear layers include bias vectors. :type has_bias: bool """ super(DD_PIHNN, self).__init__(PDE, units, material, activation, has_bias) if boundary.dd_partition is None: raise ValueError("Boundary must be initialized with well-defined 'dd_partition' in order to create DD-PIHNNs.") self.dd_partition = boundary.dd_partition self.n_domains = boundary.n_domains self.layers = torch.nn.ModuleList() for i in range(self.n_layers): self.layers.append(ComplexLinear(self.n_outputs, units[i], units[i+1], self.n_domains, has_bias=has_bias)) if (PDE == 'km-so'): raise ValueError("'stress-only' configuration cannot be applied to DD-PIHNNs.") def forward(self, z, flat_output=True, real_output=False): """ Forward step, i.e., compute for :math:`j=1,2`: .. math:: \mathcal{L}_{N,j,d} \circ \phi \circ \mathcal{L}_{N-1,j,d} \circ \phi \dots \circ \mathcal{L}_{1,j,d} (z) where :math:`z` is the input, :math:`\phi` the activation function, :math:`d\in \mathbb{N}` the domain to which :math:`z` belongs and :math:`\{\mathcal{L}_{i,j,d}\}` the complex linear layers (:class:`pihnn.nn.ComplexLinear`) for each layer :math:`i` and stacked network :math:`(j,d)`. :param z: Input of the network, typically a batch of coordinates from the domain boundary. :type z: :class:`torch.tensor` :param flat_output: If True, the output of the network is a 1D/flat vector. Otherwise, the output is a 2D tensor where the first dimension is the number of domains and the second dimension is the number of points per domain. The second option is necessary for the training of the network while one can simply consider a flat output in other circumstances. Notice that the output is flat only if the input is also flat. :type flat_output: bool :param real_output: Whether to provide the output in the real-valued representation. :type real_output: bool :returns: **phi** (:class:`torch.tensor`) - Output of the network. It has the same shape of the input for the Laplace problem but double size for the other problems. """ if (len(z.shape)==1): # Flat input z_old = z.clone() domains = self.dd_partition(z) max_size = torch.max(domains.sum(1)) z = torch.empty(self.n_domains, max_size, dtype=z_old.dtype) for d in range(self.n_domains): # We do similarly to boundary.extract_points_dd z[d,:domains[d,:].sum()] = z_old[domains[d,:]] phi = self.layers[0](z) for i in range(1, self.n_layers): phi = self.layers[i](self.activation(phi)) phi = phi.squeeze(2) if (flat_output and 'z_old' in locals()): flat_phi = torch.empty((self.n_outputs,)+z_old.shape, dtype=z_old.dtype) for d in range(self.n_domains): # We do the inverse of the previous operation flat_phi[:,domains[d,:]] = phi[:,d,:domains[d,:].sum()] phi = flat_phi z = z_old if real_output: return self.apply_real_transformation(z, phi) else: return phi def initialize_weights(self, method, beta=0.5, sample=None, gauss=None): """ Equivalent to :func:`pihnn.nn.PIHNN.init`. :param method: Either 'he', 'he_scaled', 'exp', see description above. :type method: str :param beta: Scaling coefficient in the scaled He initialization, :math:`\\beta` coefficient in the Calafà initialization, not used in He initialization. :type beta: float :param sample: Initial sample :math:`x_0` in the Calafà initialization, not used in the other methods. :type sample: :class:`torch.tensor` :param gauss: :math:`M_e` coefficient in the Calafà initialization, not used in the other methods. :type gauss: int """ if (method=='exp'): if(gauss==None): gauss = self.n_layers x = sample.clone() domains = self.dd_partition(x) max_size = torch.max(domains.sum(1)) x = torch.nan*torch.empty(self.n_domains, max_size, dtype=sample.dtype) for d in range(self.n_domains): x[d,:domains[d,:].sum()] = sample[domains[d,:]] for i in range(self.n_layers): if (i<gauss): scaling = beta/torch.nanmean(torch.pow(torch.abs(x),2)).detach() else: scaling = beta/math.exp(beta) self.layers[i].init(scaling) y = self.layers[i](x) x = self.activation(y) return if(method=='he'): scaling = 1 elif(method=='he_scaled'): scaling = beta else: raise ValueError("'method' must be either 'exp','he' or 'he_scaled'.") for i in range(self.n_layers): self.layers[i].init(scaling)