diff --git a/.github/workflows/monthly-tagger.yml b/.github/workflows/monthly-tagger.yml index 2f33486d1..ef7a7d902 100644 --- a/.github/workflows/monthly-tagger.yml +++ b/.github/workflows/monthly-tagger.yml @@ -11,7 +11,7 @@ jobs: strategy: matrix: os: [windows-latest, macos-latest, ubuntu-latest] - python-version: [3.9, '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] steps: - uses: actions/checkout@v2 - name: Set up Python diff --git a/.github/workflows/tester.yml b/.github/workflows/tester.yml index d4a182f64..dc4c63010 100644 --- a/.github/workflows/tester.yml +++ b/.github/workflows/tester.yml @@ -1,7 +1,7 @@ name: "Testing Pull Request" on: - pull_request: + pull_request_target: branches: - "master" - "dev" @@ -13,7 +13,7 @@ jobs: fail-fast: false matrix: os: [windows-latest, macos-latest, ubuntu-latest] - python-version: [3.9, '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/tutorial_exporter.yml b/.github/workflows/tutorial_exporter.yml index 957c9b52c..15d24b986 100644 --- a/.github/workflows/tutorial_exporter.yml +++ b/.github/workflows/tutorial_exporter.yml @@ -23,7 +23,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: 3.9 + python-version: 3.10 - name: Install dependencies run: | @@ -91,7 +91,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: 3.9 + python-version: 3.10 - name: Install dependencies run: | diff --git a/docs/source/_rst/_code.rst b/docs/source/_rst/_code.rst index 160eb3542..151699449 100644 --- a/docs/source/_rst/_code.rst +++ b/docs/source/_rst/_code.rst @@ -95,6 +95,7 @@ Models MultiFeedForward ResidualFeedForward Spline + SplineSurface DeepONet MIONet KernelNeuralOperator @@ -105,6 +106,8 @@ Models GraphNeuralOperator GraphNeuralKernel PirateNet + EquivariantGraphNeuralOperator + SINDy Blocks ------------- @@ -134,6 +137,7 @@ Message Passing E(n) Equivariant Network Block Interaction Network Block Radial Field Network Block + EquivariantGraphNeuralOperatorBlock Reduction and Embeddings diff --git a/docs/source/_rst/model/block/message_passing/equivariant_graph_neural_operator_block.rst b/docs/source/_rst/model/block/message_passing/equivariant_graph_neural_operator_block.rst new file mode 100644 index 000000000..8d047f84e --- /dev/null +++ b/docs/source/_rst/model/block/message_passing/equivariant_graph_neural_operator_block.rst @@ -0,0 +1,7 @@ +EquivariantGraphNeuralOperatorBlock +===================================== +.. currentmodule:: pina.model.block.message_passing.equivariant_graph_neural_operator_block + +.. autoclass:: EquivariantGraphNeuralOperatorBlock + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/_rst/model/equivariant_graph_neural_operator.rst b/docs/source/_rst/model/equivariant_graph_neural_operator.rst new file mode 100644 index 000000000..a11edcc00 --- /dev/null +++ b/docs/source/_rst/model/equivariant_graph_neural_operator.rst @@ -0,0 +1,7 @@ +EquivariantGraphNeuralOperator +================================= +.. currentmodule:: pina.model.equivariant_graph_neural_operator + +.. autoclass:: EquivariantGraphNeuralOperator + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/_rst/model/sindy.rst b/docs/source/_rst/model/sindy.rst new file mode 100644 index 000000000..bd507603b --- /dev/null +++ b/docs/source/_rst/model/sindy.rst @@ -0,0 +1,7 @@ +SINDy +======================= +.. currentmodule:: pina.model.sindy + +.. autoclass:: SINDy + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/_rst/model/spline_surface.rst b/docs/source/_rst/model/spline_surface.rst new file mode 100644 index 000000000..6bbf137d8 --- /dev/null +++ b/docs/source/_rst/model/spline_surface.rst @@ -0,0 +1,7 @@ +Spline Surface +================ +.. currentmodule:: pina.model.spline_surface + +.. autoclass:: SplineSurface + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/_tutorial.rst b/docs/source/_tutorial.rst index 2eb9c1c58..0bcc62418 100644 --- a/docs/source/_tutorial.rst +++ b/docs/source/_tutorial.rst @@ -40,5 +40,6 @@ Supervised Learning - `Introductory Tutorial: Supervised Learning with PINA `_ - `Chemical Properties Prediction with Graph Neural Networks `_ - `Reduced Order Model with Graph Neural Networks for Unstructured Domains `_ +- `Data-driven System Identification with SINDy `_ - `Unstructured Convolutional Autoencoders with Continuous Convolution `_ - `Reduced Order Modeling with POD-RBF and POD-NN Approaches for Fluid Dynamics `_ diff --git a/docs/source/tutorials/tutorial17/tutorial.html b/docs/source/tutorials/tutorial17/tutorial.html index 51508853c..5f94bab25 100644 --- a/docs/source/tutorials/tutorial17/tutorial.html +++ b/docs/source/tutorials/tutorial17/tutorial.html @@ -7620,7 +7620,7 @@

A Simple Regression Problem in PINA - - - - @@ -7893,7 +7866,7 @@

Training @@ -7921,7 +7894,8 @@

Training @@ -8137,7 +8111,7 @@

Plotting the domain @@ -8355,7 +8329,7 @@

Training

@@ -8382,12 +8356,20 @@

Training

@@ -8494,6 +8476,6 @@

References diff --git a/docs/source/tutorials/tutorial23/tutorial.html b/docs/source/tutorials/tutorial23/tutorial.html new file mode 100644 index 000000000..60d31a284 --- /dev/null +++ b/docs/source/tutorials/tutorial23/tutorial.html @@ -0,0 +1,9793 @@ + + + + + +tutorial + + + + + + + + + + + + +
+ + + + + + + + + + + +
+ + + diff --git a/pina/model/__init__.py b/pina/model/__init__.py index 5e340484e..05ccc6c8c 100644 --- a/pina/model/__init__.py +++ b/pina/model/__init__.py @@ -14,6 +14,8 @@ "Spline", "GraphNeuralOperator", "PirateNet", + "EquivariantGraphNeuralOperator", + "SINDy", ] from .feed_forward import FeedForward, ResidualFeedForward @@ -24,5 +26,8 @@ from .average_neural_operator import AveragingNeuralOperator from .low_rank_neural_operator import LowRankNeuralOperator from .spline import Spline +from .spline_surface import SplineSurface from .graph_neural_operator import GraphNeuralOperator from .pirate_network import PirateNet +from .equivariant_graph_neural_operator import EquivariantGraphNeuralOperator +from .sindy import SINDy diff --git a/pina/model/block/message_passing/__init__.py b/pina/model/block/message_passing/__init__.py index 0d432884e..202e1fde4 100644 --- a/pina/model/block/message_passing/__init__.py +++ b/pina/model/block/message_passing/__init__.py @@ -5,9 +5,13 @@ "DeepTensorNetworkBlock", "EnEquivariantNetworkBlock", "RadialFieldNetworkBlock", + "EquivariantGraphNeuralOperatorBlock", ] from .interaction_network_block import InteractionNetworkBlock from .deep_tensor_network_block import DeepTensorNetworkBlock from .en_equivariant_network_block import EnEquivariantNetworkBlock from .radial_field_network_block import RadialFieldNetworkBlock +from .equivariant_graph_neural_operator_block import ( + EquivariantGraphNeuralOperatorBlock, +) diff --git a/pina/model/block/message_passing/en_equivariant_network_block.py b/pina/model/block/message_passing/en_equivariant_network_block.py index 904c1c6c9..b8057b0f1 100644 --- a/pina/model/block/message_passing/en_equivariant_network_block.py +++ b/pina/model/block/message_passing/en_equivariant_network_block.py @@ -3,7 +3,7 @@ import torch from torch_geometric.nn import MessagePassing from torch_geometric.utils import degree -from ....utils import check_positive_integer +from ....utils import check_positive_integer, check_consistency from ....model import FeedForward @@ -27,6 +27,12 @@ class EnEquivariantNetworkBlock(MessagePassing): positions are updated by adding the incoming messages divided by the degree of the recipient node. + When velocity features are used, node velocities are passed through a small + MLP to compute updates, which are then combined with the aggregated position + messages. The node positions are updated both by the normalized position + messages and by the updated velocities, ensuring equivariance while + incorporating dynamic information. + .. seealso:: **Original reference** Satorras, V. G., Hoogeboom, E., Welling, M. @@ -40,6 +46,7 @@ def __init__( node_feature_dim, edge_feature_dim, pos_dim, + use_velocity=False, hidden_dim=64, n_message_layers=2, n_update_layers=2, @@ -54,6 +61,8 @@ def __init__( :param int node_feature_dim: The dimension of the node features. :param int edge_feature_dim: The dimension of the edge features. :param int pos_dim: The dimension of the position features. + :param bool use_velocity: Whether to use velocity features in the + message passing. Default is False. :param int hidden_dim: The dimension of the hidden features. Default is 64. :param int n_message_layers: The number of layers in the message @@ -80,6 +89,7 @@ def __init__( :raises AssertionError: If `hidden_dim` is not a positive integer. :raises AssertionError: If `n_message_layers` is not a positive integer. :raises AssertionError: If `n_update_layers` is not a positive integer. + :raises AssertionError: If `use_velocity` is not a boolean. """ super().__init__(aggr=aggr, node_dim=node_dim, flow=flow) @@ -90,6 +100,10 @@ def __init__( check_positive_integer(hidden_dim, strict=True) check_positive_integer(n_message_layers, strict=True) check_positive_integer(n_update_layers, strict=True) + check_consistency(use_velocity, bool) + + # Initialization + self.use_velocity = use_velocity # Layer for computing the message self.message_net = FeedForward( @@ -119,7 +133,17 @@ def __init__( func=activation, ) - def forward(self, x, pos, edge_index, edge_attr=None): + # If velocity is used, instantiate layer for velocity updates + if self.use_velocity: + self.update_vel_net = FeedForward( + input_dimensions=node_feature_dim, + output_dimensions=1, + inner_size=hidden_dim, + n_layers=n_update_layers, + func=activation, + ) + + def forward(self, x, pos, edge_index, edge_attr=None, vel=None): """ Forward pass of the block, triggering the message-passing routine. @@ -130,11 +154,19 @@ def forward(self, x, pos, edge_index, edge_attr=None): :param torch.Tensor edge_index: The edge indices. :param edge_attr: The edge attributes. Default is None. :type edge_attr: torch.Tensor | LabelTensor + :param vel: The velocity of the nodes. Default is None. + :type vel: torch.Tensor | LabelTensor :return: The updated node features and node positions. :rtype: tuple(torch.Tensor, torch.Tensor) + :raises: ValueError: If ``use_velocity`` is True and ``vel`` is None. """ + if self.use_velocity and vel is None: + raise ValueError( + "Velocity features are enabled, but no velocity is passed." + ) + return self.propagate( - edge_index=edge_index, x=x, pos=pos, edge_attr=edge_attr + edge_index=edge_index, x=x, pos=pos, edge_attr=edge_attr, vel=vel ) def message(self, x_i, x_j, pos_i, pos_j, edge_attr): @@ -202,10 +234,9 @@ def aggregate(self, inputs, index, ptr=None, dim_size=None): return agg_message, agg_m_ij - def update(self, aggregated_inputs, x, pos, edge_index): + def update(self, aggregated_inputs, x, pos, edge_index, vel): """ - Update the node features and the node coordinates with the received - messages. + Update node features, positions, and optionally velocities. :param tuple(torch.Tensor) aggregated_inputs: The messages to be passed. :param x: The node features. @@ -213,17 +244,26 @@ def update(self, aggregated_inputs, x, pos, edge_index): :param pos: The euclidean coordinates of the nodes. :type pos: torch.Tensor | LabelTensor :param torch.Tensor edge_index: The edge indices. + :param vel: The velocity of the nodes. + :type vel: torch.Tensor | LabelTensor :return: The updated node features and node positions. - :rtype: tuple(torch.Tensor, torch.Tensor) + :rtype: tuple(torch.Tensor, torch.Tensor) | + tuple(torch.Tensor, torch.Tensor, torch.Tensor) """ # aggregated_inputs is tuple (agg_message, agg_m_ij) agg_message, agg_m_ij = aggregated_inputs + # Degree for normalization of position updates + c = degree(edge_index[1], pos.shape[0]).unsqueeze(-1).clamp(min=1) + + # If velocity is used, update it and use it to update positions + if self.use_velocity: + vel = self.update_vel_net(x) * vel + # Update node features with aggregated m_ij x = self.update_feat_net(torch.cat((x, agg_m_ij), dim=-1)) - # Degree for normalization of position updates - c = degree(edge_index[1], pos.shape[0]).unsqueeze(-1).clamp(min=1) - pos = pos + agg_message / c + # Update positions with aggregated messages m_ij and velocities + pos = pos + agg_message / c + (vel if self.use_velocity else 0) - return x, pos + return (x, pos, vel) if self.use_velocity else (x, pos) diff --git a/pina/model/block/message_passing/equivariant_graph_neural_operator_block.py b/pina/model/block/message_passing/equivariant_graph_neural_operator_block.py new file mode 100644 index 000000000..f6c739203 --- /dev/null +++ b/pina/model/block/message_passing/equivariant_graph_neural_operator_block.py @@ -0,0 +1,188 @@ +"""Module for the Equivariant Graph Neural Operator block.""" + +import torch +from ....utils import check_positive_integer +from .en_equivariant_network_block import EnEquivariantNetworkBlock + + +class EquivariantGraphNeuralOperatorBlock(torch.nn.Module): + """ + A single block of the Equivariant Graph Neural Operator (EGNO). + + This block combines a temporal convolution with an equivariant graph neural + network (EGNN) layer. It preserves equivariance while modeling complex + interactions between nodes in a graph over time. + + .. seealso:: + + **Original reference** + Xu, M., Han, J., Lou, A., Kossaifi, J., Ramanathan, A., Azizzadenesheli, + K., Leskovec, J., Ermon, S., Anandkumar, A. (2024). + *Equivariant Graph Neural Operator for Modeling 3D Dynamics* + DOI: `arXiv preprint arXiv:2401.11037. + `_ + """ + + def __init__( + self, + node_feature_dim, + edge_feature_dim, + pos_dim, + modes, + hidden_dim=64, + n_message_layers=2, + n_update_layers=2, + activation=torch.nn.SiLU, + aggr="add", + node_dim=-2, + flow="source_to_target", + ): + """ + Initialization of the :class:`EquivariantGraphNeuralOperatorBlock` + class. + + :param int node_feature_dim: The dimension of the node features. + :param int edge_feature_dim: The dimension of the edge features. + :param int pos_dim: The dimension of the position features. + :param int modes: The number of Fourier modes to use in the temporal + convolution. + :param int hidden_dim: The dimension of the hidden features. + Default is 64. + :param int n_message_layers: The number of layers in the message + network. Default is 2. + :param int n_update_layers: The number of layers in the update network. + Default is 2. + :param torch.nn.Module activation: The activation function. + Default is :class:`torch.nn.SiLU`. + :param str aggr: The aggregation scheme to use for message passing. + Available options are "add", "mean", "min", "max", "mul". + See :class:`torch_geometric.nn.MessagePassing` for more details. + Default is "add". + :param int node_dim: The axis along which to propagate. Default is -2. + :param str flow: The direction of message passing. Available options + are "source_to_target" and "target_to_source". + The "source_to_target" flow means that messages are sent from + the source node to the target node, while the "target_to_source" + flow means that messages are sent from the target node to the + source node. See :class:`torch_geometric.nn.MessagePassing` for more + details. Default is "source_to_target". + :raises AssertionError: If ``modes`` is not a positive integer. + """ + super().__init__() + + # Check consistency + check_positive_integer(modes, strict=True) + + # Initialization + self.modes = modes + + # Temporal convolution weights - real and imaginary parts + self.weight_scalar_r = torch.nn.Parameter( + torch.rand(node_feature_dim, node_feature_dim, modes) + ) + self.weight_scalar_i = torch.nn.Parameter( + torch.rand(node_feature_dim, node_feature_dim, modes) + ) + self.weight_vector_r = torch.nn.Parameter(torch.rand(2, 2, modes) * 0.1) + self.weight_vector_i = torch.nn.Parameter(torch.rand(2, 2, modes) * 0.1) + + # EGNN block + self.egnn = EnEquivariantNetworkBlock( + node_feature_dim=node_feature_dim, + edge_feature_dim=edge_feature_dim, + pos_dim=pos_dim, + use_velocity=True, + hidden_dim=hidden_dim, + n_message_layers=n_message_layers, + n_update_layers=n_update_layers, + activation=activation, + aggr=aggr, + node_dim=node_dim, + flow=flow, + ) + + def forward(self, x, pos, vel, edge_index, edge_attr=None): + """ + Forward pass of the Equivariant Graph Neural Operator block. + + :param x: The node feature tensor of shape + ``[time_steps, num_nodes, node_feature_dim]``. + :type x: torch.Tensor | LabelTensor + :param pos: The node position tensor (Euclidean coordinates) of shape + ``[time_steps, num_nodes, pos_dim]``. + :type pos: torch.Tensor | LabelTensor + :param vel: The node velocity tensor of shape + ``[time_steps, num_nodes, pos_dim]``. + :type vel: torch.Tensor | LabelTensor + :param edge_index: The edge connectivity of shape ``[2, num_edges]``. + :type edge_index: torch.Tensor + :param edge_attr: The edge feature tensor of shape + ``[time_steps, num_edges, edge_feature_dim]``. Default is None. + :type edge_attr: torch.Tensor | LabelTensor, optional + :return: The updated node features, positions, and velocities, each with + the same shape as the inputs. + :rtype: tuple[torch.Tensor, torch.Tensor, torch.Tensor] + """ + # Prepare features + center = pos.mean(dim=1, keepdim=True) + vector = torch.stack((pos - center, vel), dim=-1) + + # Compute temporal convolution + x = x + self._convolution( + x, "mni, iom -> mno", self.weight_scalar_r, self.weight_scalar_i + ) + vector = vector + self._convolution( + vector, + "mndi, iom -> mndo", + self.weight_vector_r, + self.weight_vector_i, + ) + + # Split position and velocity + pos, vel = vector.unbind(dim=-1) + pos = pos + center + + # Reshape to (time * nodes, feature) for egnn + x = x.reshape(-1, x.shape[-1]) + pos = pos.reshape(-1, pos.shape[-1]) + vel = vel.reshape(-1, vel.shape[-1]) + if edge_attr is not None: + edge_attr = edge_attr.reshape(-1, edge_attr.shape[-1]) + + x, pos, vel = self.egnn( + x=x, + pos=pos, + edge_index=edge_index, + edge_attr=edge_attr, + vel=vel, + ) + + # Reshape back to (time, nodes, feature) + x = x.reshape(center.shape[0], -1, x.shape[-1]) + pos = pos.reshape(center.shape[0], -1, pos.shape[-1]) + vel = vel.reshape(center.shape[0], -1, vel.shape[-1]) + + return x, pos, vel + + def _convolution(self, x, einsum_idx, real, img): + """ + Compute the temporal convolution. + + :param torch.Tensor x: The input features. + :param str einsum_idx: The indices for the einsum operation. + :param torch.Tensor real: The real part of the convolution weights. + :param torch.Tensor img: The imaginary part of the convolution weights. + :return: The convolved features. + :rtype: torch.Tensor + """ + # Number of modes to use + modes = min(self.modes, (x.shape[0] // 2) + 1) + + # Build complex weights + weights = torch.complex(real[..., :modes], img[..., :modes]) + + # Convolution in Fourier space + fourier = torch.fft.rfftn(x, dim=[0])[:modes] + out = torch.einsum(einsum_idx, fourier, weights) + + return torch.fft.irfftn(out, s=x.shape[0], dim=0) diff --git a/pina/model/equivariant_graph_neural_operator.py b/pina/model/equivariant_graph_neural_operator.py new file mode 100644 index 000000000..6b33df6db --- /dev/null +++ b/pina/model/equivariant_graph_neural_operator.py @@ -0,0 +1,219 @@ +"""Module for the Equivariant Graph Neural Operator model.""" + +import torch +from ..utils import check_positive_integer +from .block.message_passing import EquivariantGraphNeuralOperatorBlock + + +class EquivariantGraphNeuralOperator(torch.nn.Module): + """ + Equivariant Graph Neural Operator (EGNO) for modeling 3D dynamics. + + EGNO is a graph-based neural operator that preserves equivariance with + respect to 3D transformations while modeling temporal and spatial + interactions between nodes. It combines: + + 1. Temporal convolution in the Fourier domain to capture long-range + temporal dependencies efficiently. + 2. Equivariant Graph Neural Network (EGNN) layers to model interactions + between nodes while respecting geometric symmetries. + + This design allows EGNO to learn complex spatiotemporal dynamics of + physical systems, molecules, or particles while enforcing physically + meaningful constraints. + + .. seealso:: + + **Original reference** + Xu, M., Han, J., Lou, A., Kossaifi, J., Ramanathan, A., Azizzadenesheli, + K., Leskovec, J., Ermon, S., Anandkumar, A. (2024). + *Equivariant Graph Neural Operator for Modeling 3D Dynamics* + DOI: `arXiv preprint arXiv:2401.11037. + `_ + """ + + def __init__( + self, + n_egno_layers, + node_feature_dim, + edge_feature_dim, + pos_dim, + modes, + time_steps=2, + hidden_dim=64, + time_emb_dim=16, + max_time_idx=10000, + n_message_layers=2, + n_update_layers=2, + activation=torch.nn.SiLU, + aggr="add", + node_dim=-2, + flow="source_to_target", + ): + """ + Initialization of the :class:`EquivariantGraphNeuralOperator` class. + + :param int n_egno_layers: The number of EGNO layers. + :param int node_feature_dim: The dimension of the node features in each + EGNO layer. + :param int edge_feature_dim: The dimension of the edge features in each + EGNO layer. + :param int pos_dim: The dimension of the position features in each + EGNO layer. + :param int modes: The number of Fourier modes to use in the temporal + convolution. + :param int time_steps: The number of time steps to consider in the + temporal convolution. Default is 2. + :param int hidden_dim: The dimension of the hidden features in each EGNO + layer. Default is 64. + :param int time_emb_dim: The dimension of the sinusoidal time + embeddings. Default is 16. + :param int max_time_idx: The maximum time index for the sinusoidal + embeddings. Default is 10000. + :param int n_message_layers: The number of layers in the message + network of each EGNO layer. Default is 2. + :param int n_update_layers: The number of layers in the update network + of each EGNO layer. Default is 2. + :param torch.nn.Module activation: The activation function. + Default is :class:`torch.nn.SiLU`. + :param str aggr: The aggregation scheme to use for message passing. + Available options are "add", "mean", "min", "max", "mul". + See :class:`torch_geometric.nn.MessagePassing` for more details. + Default is "add". + :param int node_dim: The axis along which to propagate. Default is -2. + :param str flow: The direction of message passing. Available options + are "source_to_target" and "target_to_source". + The "source_to_target" flow means that messages are sent from + the source node to the target node, while the "target_to_source" + flow means that messages are sent from the target node to the + source node. See :class:`torch_geometric.nn.MessagePassing` for more + details. Default is "source_to_target". + :raises AssertionError: If ``n_egno_layers`` is not a positive integer. + :raises AssertionError: If ``time_emb_dim`` is not a positive integer. + :raises AssertionError: If ``max_time_idx`` is not a positive integer. + :raises AssertionError: If ``time_steps`` is not a positive integer. + """ + super().__init__() + + # Check consistency + check_positive_integer(n_egno_layers, strict=True) + check_positive_integer(time_emb_dim, strict=True) + check_positive_integer(max_time_idx, strict=True) + check_positive_integer(time_steps, strict=True) + + # Initialize parameters + self.time_steps = time_steps + self.time_emb_dim = time_emb_dim + self.max_time_idx = max_time_idx + + # Initialize EGNO layers + self.egno_layers = torch.nn.ModuleList() + for _ in range(n_egno_layers): + self.egno_layers.append( + EquivariantGraphNeuralOperatorBlock( + node_feature_dim=node_feature_dim, + edge_feature_dim=edge_feature_dim, + pos_dim=pos_dim, + modes=modes, + hidden_dim=hidden_dim, + n_message_layers=n_message_layers, + n_update_layers=n_update_layers, + activation=activation, + aggr=aggr, + node_dim=node_dim, + flow=flow, + ) + ) + + # Linear layer to adjust the scalar feature dimension + self.linear = torch.nn.Linear( + node_feature_dim + time_emb_dim, node_feature_dim + ) + + def forward(self, graph): + """ + Forward pass of the :class:`EquivariantGraphNeuralOperator` class. + + :param graph: The input graph object with the following attributes: + - 'x': Node features, shape ``[num_nodes, node_feature_dim]``. + - 'pos': Node positions, shape ``[num_nodes, pos_dim]``. + - 'vel': Node velocities, shape ``[num_nodes, pos_dim]``. + - 'edge_index': Graph connectivity, shape ``[2, num_edges]``. + - 'edge_attr': Edge attrs, shape ``[num_edges, edge_feature_dim]``. + :type graph: Data | Graph + :return: The output graph object with updated node features, + positions, and velocities. The output graph adds to 'x', 'pos', + 'vel', and 'edge_attr' the time dimension, resulting in shapes: + - 'x': ``[time_steps, num_nodes, node_feature_dim]`` + - 'pos': ``[time_steps, num_nodes, pos_dim]`` + - 'vel': ``[time_steps, num_nodes, pos_dim]`` + - 'edge_attr': ``[time_steps, num_edges, edge_feature_dim]`` + :rtype: Data | Graph + :raises ValueError: If the input graph does not have a 'vel' attribute. + """ + # Check that the graph has the required attributes + if "vel" not in graph: + raise ValueError("The input graph must have a 'vel' attribute.") + + # Compute the temporal embedding + emb = self._embedding(torch.arange(self.time_steps)).to(graph.x.device) + emb = emb.unsqueeze(1).repeat(1, graph.x.shape[0], 1) + + # Expand dimensions + x = graph.x.unsqueeze(0).repeat(self.time_steps, 1, 1) + x = self.linear(torch.cat((x, emb), dim=-1)) + pos = graph.pos.unsqueeze(0).repeat(self.time_steps, 1, 1) + vel = graph.vel.unsqueeze(0).repeat(self.time_steps, 1, 1) + + # Manage edge index + offset = torch.arange(self.time_steps).reshape(-1, 1) + offset = offset.to(graph.x.device) * graph.x.shape[0] + src = graph.edge_index[0].unsqueeze(0) + offset + dst = graph.edge_index[1].unsqueeze(0) + offset + edge_index = torch.stack([src, dst], dim=0).reshape(2, -1) + + # Manage edge attributes + if graph.edge_attr is not None: + edge_attr = graph.edge_attr.unsqueeze(0) + edge_attr = edge_attr.repeat(self.time_steps, 1, 1) + else: + edge_attr = None + + # Iteratively apply EGNO layers + for layer in self.egno_layers: + x, pos, vel = layer( + x=x, + pos=pos, + vel=vel, + edge_index=edge_index, + edge_attr=edge_attr, + ) + + # Build new graph + new_graph = graph.clone() + new_graph.x, new_graph.pos, new_graph.vel = x, pos, vel + if edge_attr is not None: + new_graph.edge_attr = edge_attr + + return new_graph + + def _embedding(self, time): + """ + Generate sinusoidal temporal embeddings. + + :param torch.Tensor time: The time instances. + :return: The sinusoidal embedding tensor. + :rtype: torch.Tensor + """ + # Compute the sinusoidal embeddings + half_dim = self.time_emb_dim // 2 + logs = torch.log(torch.as_tensor(self.max_time_idx)) / (half_dim - 1) + freqs = torch.exp(-torch.arange(half_dim) * logs) + args = torch.as_tensor(time)[:, None] * freqs[None, :] + emb = torch.cat([torch.sin(args), torch.cos(args)], dim=-1) + + # Apply padding if the embedding dimension is odd + if self.time_emb_dim % 2 == 1: + emb = torch.nn.functional.pad(emb, (0, 1), mode="constant") + + return emb diff --git a/pina/model/sindy.py b/pina/model/sindy.py new file mode 100644 index 000000000..a40fa37b4 --- /dev/null +++ b/pina/model/sindy.py @@ -0,0 +1,102 @@ +"""Module for the SINDy model class.""" + +from typing import Callable +import torch +from ..utils import check_consistency, check_positive_integer + + +class SINDy(torch.nn.Module): + r""" + SINDy model class. + + The Sparse Identification of Nonlinear Dynamics (SINDy) model identifies the + governing equations of a dynamical system from data by learning a sparse + linear combination of non-linear candidate functions. + + The output of the model is expressed as product of a library matrix and a + coefficient matrix: + + .. math:: + + \dot{X} = \Theta(X) \Xi + + where: + - :math:`X \in \mathbb{R}^{B \times D}` is the input snapshots of the + system state. Here, :math:`B` is the batch size and :math:`D` is the + number of state variables. + - :math:`\Theta(X) \in \mathbb{R}^{B \times L}` is the library matrix + obtained by evaluating a set of candidate functions on the input data. + Here, :math:`L` is the number of candidate functions in the library. + - :math:`\Xi \in \mathbb{R}^{L \times D}` is the learned coefficient + matrix that defines the sparse model. + + .. seealso:: + + **Original reference**: + Brunton, S.L., Proctor, J.L., and Kutz, J.N. (2016). + *Discovering governing equations from data: Sparse identification of + non-linear dynamical systems.* + Proceedings of the National Academy of Sciences, 113(15), 3932-3937. + DOI: `10.1073/pnas.1517384113 + `_ + """ + + def __init__(self, library, output_dimension): + """ + Initialization of the :class:`SINDy` class. + + :param list[Callable] library: The collection of candidate functions + used to construct the library matrix. Each function must accept an + input tensor of shape ``[..., D]`` and return a tensor of shape + ``[..., 1]``. + :param int output_dimension: The number of output variables, typically + the number of state derivatives. It determines the number of columns + in the coefficient matrix. + :raises ValueError: If ``library`` is not a list of callables. + :raises AssertionError: If ``output_dimension`` is not a positive + integer. + """ + super().__init__() + + # Check consistency + check_positive_integer(output_dimension, strict=True) + check_consistency(library, Callable) + if not isinstance(library, list): + raise ValueError("`library` must be a list of callables.") + + # Initialization + self._library = library + self._coefficients = torch.nn.Parameter( + torch.zeros(len(library), output_dimension) + ) + + def forward(self, x): + """ + Forward pass of the :class:`SINDy` model. + + :param torch.Tensor x: The input batch of state variables. + :return: The predicted time derivatives of the state variables. + :rtype: torch.Tensor + """ + theta = torch.stack([f(x) for f in self.library], dim=-2) + return torch.einsum("...li , lo -> ...o", theta, self.coefficients) + + @property + def library(self): + """ + The library of candidate functions. + + :return: The library. + :rtype: list[Callable] + """ + return self._library + + @property + def coefficients(self): + """ + The coefficients of the model. + + :return: The coefficients. + :rtype: torch.Tensor + """ + return self._coefficients diff --git a/pina/model/spline.py b/pina/model/spline.py index c22c7937c..a276a6cfd 100644 --- a/pina/model/spline.py +++ b/pina/model/spline.py @@ -1,109 +1,244 @@ -"""Module for the Spline model class.""" +"""Module for the B-Spline model class.""" +import warnings import torch -from ..utils import check_consistency +from ..utils import check_positive_integer, check_consistency class Spline(torch.nn.Module): - """ - Spline model class. + r""" + The univariate B-Spline curve model class. + + A univariate B-spline curve of order :math:`k` is a parametric curve defined + as a linear combination of B-spline basis functions and control points: + + .. math:: + + S(x) = \sum_{i=1}^{n} B_{i,k}(x) C_i, \quad x \in [x_1, x_m] + + where: + + - :math:`C_i \in \mathbb{R}` are the control points. These fixed points + influence the shape of the curve but are not generally interpolated, + except at the boundaries under certain knot multiplicities. + - :math:`B_{i,k}(x)` are the B-spline basis functions of order :math:`k`, + i.e., piecewise polynomials of degree :math:`k-1` with support on the + interval :math:`[x_i, x_{i+k}]`. + - :math:`X = \{ x_1, x_2, \dots, x_m \}` is the non-decreasing knot vector. + + If the first and last knots are repeated :math:`k` times, then the curve + interpolates the first and last control points. + + + .. note:: + + The curve is forced to be zero outside the interval defined by the + first and last knots. + + + :Example: + + >>> from pina.model import Spline + >>> import torch + + >>> knots1 = torch.tensor([0.0, 0.0, 0.0, 1.0, 2.0, 2.0, 2.0]) + >>> spline1 = Spline(order=3, knots=knots1, control_points=None) + + >>> knots2 = {"n": 7, "min": 0.0, "max": 2.0, "mode": "auto"} + >>> spline2 = Spline(order=3, knots=knots2, control_points=None) + + >>> knots3 = torch.tensor([0.0, 0.0, 0.0, 1.0, 2.0, 2.0, 2.0]) + >>> control_points3 = torch.tensor([0.0, 1.0, 3.0, 2.0]) + >>> spline3 = Spline(order=3, knots=knots3, control_points=control_points3) """ - def __init__(self, order=4, knots=None, control_points=None) -> None: + def __init__(self, order=4, knots=None, control_points=None): """ Initialization of the :class:`Spline` class. - :param int order: The order of the spline. Default is ``4``. - :param torch.Tensor knots: The tensor representing knots. If ``None``, - the knots will be initialized automatically. Default is ``None``. - :param torch.Tensor control_points: The control points. Default is - ``None``. - :raises ValueError: If the order is negative. - :raises ValueError: If both knots and control points are ``None``. - :raises ValueError: If the knot tensor is not one-dimensional. + :param int order: The order of the spline. The corresponding basis + functions are polynomials of degree ``order - 1``. Default is 4. + :param knots: The knots of the spline. If a tensor is provided, knots + are set directly from the tensor. If a dictionary is provided, it + must contain the keys ``"n"``, ``"min"``, ``"max"``, and ``"mode"``. + Here, ``"n"`` specifies the number of knots, ``"min"`` and ``"max"`` + define the interval, and ``"mode"`` selects the sampling strategy. + The supported modes are ``"uniform"``, where the knots are evenly + spaced over :math:`[min, max]`, and ``"auto"``, where knots are + constructed to ensure that the spline interpolates the first and + last control points. In this case, the number of knots is adjusted + if :math:`n < 2 * order`. If None is given, knots are initialized + automatically over :math:`[0, 1]` ensuring interpolation of the + first and last control points. Default is None. + :type knots: torch.Tensor | dict + :param torch.Tensor control_points: The control points of the spline. + If None, they are initialized as learnable parameters with an + initial value of zero. Default is None. + :raises AssertionError: If ``order`` is not a positive integer. + :raises ValueError: If ``knots`` is neither a torch.Tensor nor a + dictionary, when provided. + :raises ValueError: If ``control_points`` is not a torch.Tensor, + when provided. + :raises ValueError: If both ``knots`` and ``control_points`` are None. + :raises ValueError: If ``knots`` is not one-dimensional. + :raises ValueError: If ``control_points`` is not one-dimensional. + :raises ValueError: If the number of ``knots`` is not equal to the sum + of ``order`` and the number of ``control_points.`` + :raises UserWarning: If the number of control points is lower than the + order, resulting in a degenerate spline. """ super().__init__() - check_consistency(order, int) + # Check consistency + check_positive_integer(value=order, strict=True) + check_consistency(knots, (type(None), torch.Tensor, dict)) + check_consistency(control_points, (type(None), torch.Tensor)) - if order < 0: - raise ValueError("Spline order cannot be negative.") + # Raise error if neither knots nor control points are provided if knots is None and control_points is None: - raise ValueError("Knots and control points cannot be both None.") - - self.order = order - self.k = order - 1 + raise ValueError("knots and control_points cannot both be None.") - if knots is not None and control_points is not None: - self.knots = knots - self.control_points = control_points + # Initialize knots if not provided + if knots is None and control_points is not None: + knots = { + "n": len(control_points) + order, + "min": 0, + "max": 1, + "mode": "auto", + } - elif knots is not None: - print("Warning: control points will be initialized automatically.") - print(" experimental feature") + # Initialization - knots and control points managed by their setters + self.order = order + self.knots = knots + self.control_points = control_points + + # Check dimensionality of knots + if self.knots.ndim > 1: + raise ValueError("knots must be one-dimensional.") + + # Check dimensionality of control points + if self.control_points.ndim > 1: + raise ValueError("control_points must be one-dimensional.") + + # Raise error if #knots != order + #control_points + if len(self.knots) != self.order + len(self.control_points): + raise ValueError( + f" The number of knots must be equal to order + number of" + f" control points. Got {len(self.knots)} knots, {self.order}" + f" order and {len(self.control_points)} control points." + ) - self.knots = knots - n = len(knots) - order - self.control_points = torch.nn.Parameter( - torch.zeros(n), requires_grad=True + # Raise warning if spline is degenerate + if len(self.control_points) < self.order: + warnings.warn( + "The number of control points is smaller than the spline order." + " This creates a degenerate spline with limited flexibility.", + UserWarning, ) - elif control_points is not None: - print("Warning: knots will be initialized automatically.") - print(" experimental feature") + # Precompute boundary interval index + self._boundary_interval_idx = self._compute_boundary_interval() - self.control_points = control_points + def _compute_boundary_interval(self): + """ + Precompute the index of the rightmost non-degenerate interval to improve + performance, eliminating the need to perform a search loop in the basis + function on each call. - n = len(self.control_points) - 1 - self.knots = { - "type": "auto", - "min": 0, - "max": 1, - "n": n + 2 + self.order, - } + :return: The index of the rightmost non-degenerate interval. + :rtype: int + """ + # Return 0 if there is a single interval + if len(self.knots) < 2: + return 0 + + # Find all indices where knots are strictly increasing + diffs = self.knots[1:] - self.knots[:-1] + valid = torch.nonzero(diffs > 0, as_tuple=False) - else: - raise ValueError("Knots and control points cannot be both None.") + # If all knots are equal, return 0 for degenerate spline + if valid.numel() == 0: + return 0 - if self.knots.ndim != 1: - raise ValueError("Knot vector must be one-dimensional.") + # Otherwise, return the last valid index + return int(valid[-1]) - def basis(self, x, k, i, t): + def basis(self, x): """ - Recursive method to compute the basis functions of the spline. + Compute the basis functions for the spline using an iterative approach. + This is a vectorized implementation based on the Cox-de Boor recursion. :param torch.Tensor x: The points to be evaluated. - :param int k: The spline degree. - :param int i: The index of the interval. - :param torch.Tensor t: The tensor of knots. - :return: The basis functions evaluated at x + :return: The basis functions evaluated at x. :rtype: torch.Tensor """ + # Add a final dimension to x + x = x.unsqueeze(-1) + + # Add an initial dimension to knots + knots = self.knots.unsqueeze(0) - if k == 0: - a = torch.where( - torch.logical_and(t[i] <= x, x < t[i + 1]), 1.0, 0.0 + # Base case of recursion: indicator functions for the intervals + basis = (x >= knots[..., :-1]) & (x < knots[..., 1:]) + basis = basis.to(x.dtype) + + # One-dimensional knots case: ensure rightmost boundary inclusion + if self._boundary_interval_idx is not None: + + # Extract left and right knots of the rightmost interval + knot_left = knots[..., self._boundary_interval_idx] + knot_right = knots[..., self._boundary_interval_idx + 1] + + # Identify points at the rightmost boundary + at_rightmost_boundary = ( + x.squeeze(-1) >= knot_left + ) & torch.isclose(x.squeeze(-1), knot_right, rtol=1e-8, atol=1e-10) + + # Ensure the correct value is set at the rightmost boundary + if torch.any(at_rightmost_boundary): + basis[..., self._boundary_interval_idx] = torch.logical_or( + basis[..., self._boundary_interval_idx].bool(), + at_rightmost_boundary, + ).to(basis.dtype) + + # Iterative case of recursion + for i in range(1, self.order): + + # Compute the denominators for both terms + denom1 = knots[..., i:-1] - knots[..., : -(i + 1)] + denom2 = knots[..., i + 1 :] - knots[..., 1:-i] + + # Ensure no division by zero + denom1 = torch.where( + torch.abs(denom1) < 1e-8, torch.ones_like(denom1), denom1 ) - if i == len(t) - self.order - 1: - a = torch.where(x == t[-1], 1.0, a) - a.requires_grad_(True) - return a - - if t[i + k] == t[i]: - c1 = torch.tensor([0.0] * len(x), requires_grad=True) - else: - c1 = (x - t[i]) / (t[i + k] - t[i]) * self.basis(x, k - 1, i, t) - - if t[i + k + 1] == t[i + 1]: - c2 = torch.tensor([0.0] * len(x), requires_grad=True) - else: - c2 = ( - (t[i + k + 1] - x) - / (t[i + k + 1] - t[i + 1]) - * self.basis(x, k - 1, i + 1, t) + denom2 = torch.where( + torch.abs(denom2) < 1e-8, torch.ones_like(denom2), denom2 ) - return c1 + c2 + # Compute the two terms of the recursion + term1 = ((x - knots[..., : -(i + 1)]) / denom1) * basis[..., :-1] + term2 = ((knots[..., i + 1 :] - x) / denom2) * basis[..., 1:] + + # Combine terms to get the new basis + basis = term1 + term2 + + return basis + + def forward(self, x): + """ + Forward pass for the :class:`Spline` model. + + :param x: The input tensor. + :type x: torch.Tensor | LabelTensor + :return: The output tensor. + :rtype: torch.Tensor + """ + return torch.einsum( + "...bi, i -> ...b", + self.basis(x.as_subclass(torch.Tensor)).squeeze(-1), + self.control_points, + ) @property def control_points(self): @@ -116,24 +251,34 @@ def control_points(self): return self._control_points @control_points.setter - def control_points(self, value): + def control_points(self, control_points): """ Set the control points of the spline. - :param value: The control points. - :type value: torch.Tensor | dict - :raises ValueError: If invalid value is passed. + :param torch.Tensor control_points: The control points tensor. If None, + control points are initialized to learnable parameters with zero + initial value. Default is None. + :raises ValueError: If there are not enough knots to define the control + points, due to the relation: #knots = order + #control_points. """ - if isinstance(value, dict): - if "n" not in value: - raise ValueError("Invalid value for control_points") - n = value["n"] - dim = value.get("dim", 1) - value = torch.zeros(n, dim) + # If control points are not provided, initialize them + if control_points is None: - if not isinstance(value, torch.Tensor): - raise ValueError("Invalid value for control_points") - self._control_points = torch.nn.Parameter(value, requires_grad=True) + # Check that there are enough knots to define control points + if len(self.knots) < self.order + 1: + raise ValueError( + f"Not enough knots to define control points. Got " + f"{len(self.knots)} knots, but need at least " + f"{self.order + 1}." + ) + + # Initialize control points to zero + control_points = torch.zeros(len(self.knots) - self.order) + + # Set control points + self._control_points = torch.nn.Parameter( + control_points, requires_grad=True + ) @property def knots(self): @@ -150,50 +295,72 @@ def knots(self, value): """ Set the knots of the spline. - :param value: The knots. + :param value: The knots of the spline. If a tensor is provided, knots + are set directly from the tensor. If a dictionary is provided, it + must contain the keys ``"n"``, ``"min"``, ``"max"``, and ``"mode"``. + Here, ``"n"`` specifies the number of knots, ``"min"`` and ``"max"`` + define the interval, and ``"mode"`` selects the sampling strategy. + The supported modes are ``"uniform"``, where the knots are evenly + spaced over :math:`[min, max]`, and ``"auto"``, where knots are + constructed to ensure that the spline interpolates the first and + last control points. In this case, the number of knots is inferred + and the ``"n"`` key is ignored. :type value: torch.Tensor | dict - :raises ValueError: If invalid value is passed. + :raises ValueError: If a dictionary is provided but does not contain + the required keys. + :raises ValueError: If the mode specified in the dictionary is invalid. """ + # If a dictionary is provided, initialize knots accordingly if isinstance(value, dict): - type_ = value.get("type", "auto") - min_ = value.get("min", 0) - max_ = value.get("max", 1) - n = value.get("n", 10) - - if type_ == "uniform": - value = torch.linspace(min_, max_, n + self.k + 1) - elif type_ == "auto": - initial_knots = torch.ones(self.order + 1) * min_ - final_knots = torch.ones(self.order + 1) * max_ - - if n < self.order + 1: - value = torch.concatenate((initial_knots, final_knots)) - elif n - 2 * self.order + 1 == 1: - value = torch.Tensor([(max_ + min_) / 2]) - else: - value = torch.linspace(min_, max_, n - 2 * self.order - 1) + # Check that required keys are present + required_keys = {"n", "min", "max", "mode"} + if not required_keys.issubset(value.keys()): + raise ValueError( + f"When providing knots as a dictionary, the following " + f"keys must be present: {required_keys}. Got " + f"{value.keys()}." + ) - value = torch.concatenate((initial_knots, value, final_knots)) + # Uniform sampling of knots + if value["mode"] == "uniform": + value = torch.linspace(value["min"], value["max"], value["n"]) - if not isinstance(value, torch.Tensor): - raise ValueError("Invalid value for knots") + # Automatic sampling of interpolating knots + elif value["mode"] == "auto": - self._knots = value + # Repeat the first and last knots 'order' times + initial_knots = torch.ones(self.order) * value["min"] + final_knots = torch.ones(self.order) * value["max"] - def forward(self, x): - """ - Forward pass for the :class:`Spline` model. + # Number of internal knots + n_internal = value["n"] - 2 * self.order - :param torch.Tensor x: The input tensor. - :return: The output tensor. - :rtype: torch.Tensor - """ - t = self.knots - k = self.k - c = self.control_points - - basis = map(lambda i: self.basis(x, k, i, t)[:, None], range(len(c))) - y = (torch.cat(list(basis), dim=1) * c).sum(axis=1) + # If no internal knots are needed, just concatenate boundaries + if n_internal <= 0: + value = torch.cat((initial_knots, final_knots)) - return y + # Else, sample internal knots uniformly and exclude boundaries + # Recover the correct number of internal knots when slicing by + # adding 2 to n_internal + else: + internal_knots = torch.linspace( + value["min"], value["max"], n_internal + 2 + )[1:-1] + value = torch.cat( + (initial_knots, internal_knots, final_knots) + ) + + # Raise error if mode is invalid + else: + raise ValueError( + f"Invalid mode for knots initialization. Got " + f"{value['mode']}, but expected 'uniform' or 'auto'." + ) + + # Set knots + self.register_buffer("_knots", value.sort(dim=0).values) + + # Recompute boundary interval when knots change + if hasattr(self, "_boundary_interval_idx"): + self._boundary_interval_idx = self._compute_boundary_interval() diff --git a/pina/model/spline_surface.py b/pina/model/spline_surface.py new file mode 100644 index 000000000..30d41bbde --- /dev/null +++ b/pina/model/spline_surface.py @@ -0,0 +1,212 @@ +"""Module for the bivariate B-Spline surface model class.""" + +import torch +from .spline import Spline +from ..utils import check_consistency + + +class SplineSurface(torch.nn.Module): + r""" + The bivariate B-Spline surface model class. + + A bivariate B-spline surface is a parametric surface defined as the tensor + product of two univariate B-spline curves: + + .. math:: + + S(x, y) = \sum_{i,j=1}^{n_x, n_y} B_{i,k}(x) B_{j,s}(y) C_{i,j}, + \quad x \in [x_1, x_m], y \in [y_1, y_l] + + where: + + - :math:`C_{i,j} \in \mathbb{R}^2` are the control points. These fixed + points influence the shape of the surface but are not generally + interpolated, except at the boundaries under certain knot multiplicities. + - :math:`B_{i,k}(x)` and :math:`B_{j,s}(y)` are the B-spline basis functions + defined over two orthogonal directions, with orders :math:`k` and + :math:`s`, respectively. + - :math:`X = \{ x_1, x_2, \dots, x_m \}` and + :math:`Y = \{ y_1, y_2, \dots, y_l \}` are the non-decreasing knot + vectors along the two directions. + """ + + def __init__(self, orders, knots_u=None, knots_v=None, control_points=None): + """ + Initialization of the :class:`SplineSurface` class. + + :param list[int] orders: The orders of the spline along each parametric + direction. Each order defines the degree of the corresponding basis + as ``degree = order - 1``. + :param knots_u: The knots of the spline along the first direction. + For details on valid formats and initialization modes, see the + :class:`Spline` class. Default is None. + :type knots_u: torch.Tensor | dict + :param knots_v: The knots of the spline along the second direction. + For details on valid formats and initialization modes, see the + :class:`Spline` class. Default is None. + :type knots_v: torch.Tensor | dict + :param torch.Tensor control_points: The control points defining the + surface geometry. It must be a two-dimensional tensor of shape + ``[len(knots_u) - orders[0], len(knots_v) - orders[1]]``. + If None, they are initialized as learnable parameters with zero + values. Default is None. + :raises ValueError: If ``orders`` is not a list of integers. + :raises ValueError: If ``knots_u`` is neither a torch.Tensor nor a + dictionary, when provided. + :raises ValueError: If ``knots_v`` is neither a torch.Tensor nor a + dictionary, when provided. + :raises ValueError: If ``control_points`` is not a torch.Tensor, + when provided. + :raises ValueError: If ``orders`` is not a list of two elements. + :raises ValueError: If ``knots_u``, ``knots_v``, and ``control_points`` + are all None. + """ + super().__init__() + + # Check consistency + check_consistency(orders, int) + check_consistency(control_points, (type(None), torch.Tensor)) + check_consistency(knots_u, (type(None), torch.Tensor, dict)) + check_consistency(knots_v, (type(None), torch.Tensor, dict)) + + # Check orders is a list of two elements + if len(orders) != 2: + raise ValueError("orders must be a list of two elements.") + + # Raise error if neither knots nor control points are provided + if (knots_u is None or knots_v is None) and control_points is None: + raise ValueError( + "control_points cannot be None if knots_u or knots_v is None." + ) + + # Initialize knots_u if not provided + if knots_u is None and control_points is not None: + knots_u = { + "n": control_points.shape[0] + orders[0], + "min": 0, + "max": 1, + "mode": "auto", + } + + # Initialize knots_v if not provided + if knots_v is None and control_points is not None: + knots_v = { + "n": control_points.shape[1] + orders[1], + "min": 0, + "max": 1, + "mode": "auto", + } + + # Create two univariate b-splines + self.spline_u = Spline(order=orders[0], knots=knots_u) + self.spline_v = Spline(order=orders[1], knots=knots_v) + self.control_points = control_points + + # Delete unneeded parameters + delattr(self.spline_u, "_control_points") + delattr(self.spline_v, "_control_points") + + def forward(self, x): + """ + Forward pass for the :class:`SplineSurface` model. + + :param x: The input tensor. + :type x: torch.Tensor | LabelTensor + :return: The output tensor. + :rtype: torch.Tensor + """ + return torch.einsum( + "...bi, ...bj, ij -> ...b", + self.spline_u.basis(x.as_subclass(torch.Tensor)[..., 0]), + self.spline_v.basis(x.as_subclass(torch.Tensor)[..., 1]), + self.control_points, + ).unsqueeze(-1) + + @property + def knots(self): + """ + The knots of the univariate splines defining the spline surface. + + :return: The knots. + :rtype: tuple(torch.Tensor, torch.Tensor) + """ + return self.spline_u.knots, self.spline_v.knots + + @knots.setter + def knots(self, value): + """ + Set the knots of the spline surface. + + :param value: A tuple (knots_u, knots_v) containing the knots for both + parametric directions. + :type value: tuple(torch.Tensor | dict, torch.Tensor | dict) + :raises ValueError: If value is not a tuple of two elements. + """ + # Check value is a tuple of two elements + if not (isinstance(value, tuple) and len(value) == 2): + raise ValueError("Knots must be a tuple of two elements.") + + knots_u, knots_v = value + self.spline_u.knots = knots_u + self.spline_v.knots = knots_v + + @property + def control_points(self): + """ + The control points of the spline. + + :return: The control points. + :rtype: torch.Tensor + """ + return self._control_points + + @control_points.setter + def control_points(self, control_points): + """ + Set the control points of the spline surface. + + :param torch.Tensor control_points: The bidimensional control points + tensor, where each dimension refers to a direction in the parameter + space. If None, control points are initialized to learnable + parameters with zero initial value. Default is None. + :raises ValueError: If in any direction there are not enough knots to + define the control points, due to the relation: + #knots = order + #control_points. + :raises ValueError: If ``control_points`` is not of the correct shape. + """ + # Save correct shape of control points + __valid_shape = ( + len(self.spline_u.knots) - self.spline_u.order, + len(self.spline_v.knots) - self.spline_v.order, + ) + + # If control points are not provided, initialize them + if control_points is None: + + # Check that there are enough knots to define control points + if ( + len(self.spline_u.knots) < self.spline_u.order + 1 + or len(self.spline_v.knots) < self.spline_v.order + 1 + ): + raise ValueError( + f"Not enough knots to define control points. Got " + f"{len(self.spline_u.knots)} knots along u and " + f"{len(self.spline_v.knots)} knots along v, but need at " + f"least {self.spline_u.order + 1} and " + f"{self.spline_v.order + 1}, respectively." + ) + + # Initialize control points to zero + control_points = torch.zeros(__valid_shape) + + # Check control points + if control_points.shape != __valid_shape: + raise ValueError( + "control_points must be of the correct shape. ", + f"Expected {__valid_shape}, got {control_points.shape}.", + ) + + # Register control points as a learnable parameter + self._control_points = torch.nn.Parameter( + control_points, requires_grad=True + ) diff --git a/pyproject.toml b/pyproject.toml index 1eb1583bb..aa4a1947a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pina-mathlab" -version = "0.2.3" +version = "0.2.4" description = "Physic Informed Neural networks for Advance modeling." readme = "README.md" authors = [ @@ -19,7 +19,7 @@ dependencies = [ "torch_geometric", "matplotlib", ] -requires-python = ">=3.9" +requires-python = ">=3.10" [project.optional-dependencies] doc = [ diff --git a/tests/test_messagepassing/test_equivariant_network_block.py b/tests/test_messagepassing/test_equivariant_network_block.py index eea000a0e..01434408f 100644 --- a/tests/test_messagepassing/test_equivariant_network_block.py +++ b/tests/test_messagepassing/test_equivariant_network_block.py @@ -5,19 +5,22 @@ # Data for testing x = torch.rand(10, 4) pos = torch.rand(10, 3) -edge_index = torch.randint(0, 10, (2, 20)) -edge_attr = torch.randn(20, 2) +velocity = torch.rand(10, 3) +edge_idx = torch.randint(0, 10, (2, 20)) +edge_attributes = torch.randn(20, 2) @pytest.mark.parametrize("node_feature_dim", [1, 3]) @pytest.mark.parametrize("edge_feature_dim", [0, 2]) @pytest.mark.parametrize("pos_dim", [2, 3]) -def test_constructor(node_feature_dim, edge_feature_dim, pos_dim): +@pytest.mark.parametrize("use_velocity", [True, False]) +def test_constructor(node_feature_dim, edge_feature_dim, pos_dim, use_velocity): EnEquivariantNetworkBlock( node_feature_dim=node_feature_dim, edge_feature_dim=edge_feature_dim, pos_dim=pos_dim, + use_velocity=use_velocity, hidden_dim=64, n_message_layers=2, n_update_layers=2, @@ -29,6 +32,7 @@ def test_constructor(node_feature_dim, edge_feature_dim, pos_dim): node_feature_dim=-1, edge_feature_dim=edge_feature_dim, pos_dim=pos_dim, + use_velocity=use_velocity, ) # Should fail if edge_feature_dim is negative @@ -37,6 +41,7 @@ def test_constructor(node_feature_dim, edge_feature_dim, pos_dim): node_feature_dim=node_feature_dim, edge_feature_dim=-1, pos_dim=pos_dim, + use_velocity=use_velocity, ) # Should fail if pos_dim is negative @@ -45,6 +50,7 @@ def test_constructor(node_feature_dim, edge_feature_dim, pos_dim): node_feature_dim=node_feature_dim, edge_feature_dim=edge_feature_dim, pos_dim=-1, + use_velocity=use_velocity, ) # Should fail if hidden_dim is negative @@ -54,6 +60,7 @@ def test_constructor(node_feature_dim, edge_feature_dim, pos_dim): edge_feature_dim=edge_feature_dim, pos_dim=pos_dim, hidden_dim=-1, + use_velocity=use_velocity, ) # Should fail if n_message_layers is negative @@ -63,6 +70,7 @@ def test_constructor(node_feature_dim, edge_feature_dim, pos_dim): edge_feature_dim=edge_feature_dim, pos_dim=pos_dim, n_message_layers=-1, + use_velocity=use_velocity, ) # Should fail if n_update_layers is negative @@ -72,11 +80,22 @@ def test_constructor(node_feature_dim, edge_feature_dim, pos_dim): edge_feature_dim=edge_feature_dim, pos_dim=pos_dim, n_update_layers=-1, + use_velocity=use_velocity, + ) + + # Should fail if use_velocity is not boolean + with pytest.raises(ValueError): + EnEquivariantNetworkBlock( + node_feature_dim=node_feature_dim, + edge_feature_dim=edge_feature_dim, + pos_dim=pos_dim, + use_velocity="False", ) @pytest.mark.parametrize("edge_feature_dim", [0, 2]) -def test_forward(edge_feature_dim): +@pytest.mark.parametrize("use_velocity", [True, False]) +def test_forward(edge_feature_dim, use_velocity): model = EnEquivariantNetworkBlock( node_feature_dim=x.shape[1], @@ -85,21 +104,26 @@ def test_forward(edge_feature_dim): hidden_dim=64, n_message_layers=2, n_update_layers=2, + use_velocity=use_velocity, ) - if edge_feature_dim == 0: - output_ = model(edge_index=edge_index, x=x, pos=pos) - else: - output_ = model( - edge_index=edge_index, x=x, pos=pos, edge_attr=edge_attr - ) + # Manage inputs + vel = velocity if use_velocity else None + edge_attr = edge_attributes if edge_feature_dim > 0 else None + # Checks on output shapes + output_ = model( + x=x, pos=pos, edge_index=edge_idx, edge_attr=edge_attr, vel=vel + ) assert output_[0].shape == x.shape assert output_[1].shape == pos.shape + if vel is not None: + assert output_[2].shape == vel.shape @pytest.mark.parametrize("edge_feature_dim", [0, 2]) -def test_backward(edge_feature_dim): +@pytest.mark.parametrize("use_velocity", [True, False]) +def test_backward(edge_feature_dim, use_velocity): model = EnEquivariantNetworkBlock( node_feature_dim=x.shape[1], @@ -108,35 +132,45 @@ def test_backward(edge_feature_dim): hidden_dim=64, n_message_layers=2, n_update_layers=2, + use_velocity=use_velocity, + ) + + # Manage inputs + vel = velocity.requires_grad_() if use_velocity else None + edge_attr = ( + edge_attributes.requires_grad_() if edge_feature_dim > 0 else None ) if edge_feature_dim == 0: output_ = model( - edge_index=edge_index, + edge_index=edge_idx, x=x.requires_grad_(), pos=pos.requires_grad_(), + vel=vel, ) else: output_ = model( - edge_index=edge_index, + edge_index=edge_idx, x=x.requires_grad_(), pos=pos.requires_grad_(), - edge_attr=edge_attr.requires_grad_(), + edge_attr=edge_attr, + vel=vel, ) - loss = torch.mean(output_[0]) + # Checks on gradients + loss = sum(torch.mean(output_[i]) for i in range(len(output_))) loss.backward() assert x.grad.shape == x.shape assert pos.grad.shape == pos.shape + if use_velocity: + assert vel.grad.shape == vel.shape -def test_equivariance(): - - # Graph to be fully connected and undirected - edge_index = torch.combinations(torch.arange(x.shape[0]), r=2).T - edge_index = torch.cat([edge_index, edge_index.flip(0)], dim=1) +@pytest.mark.parametrize("edge_feature_dim", [0, 2]) +@pytest.mark.parametrize("use_velocity", [True, False]) +def test_equivariance(edge_feature_dim, use_velocity): - # Random rotation (det(rotation) should be 1) + # Random rotation rotation = torch.linalg.qr(torch.rand(pos.shape[-1], pos.shape[-1])).Q if torch.det(rotation) < 0: rotation[:, 0] *= -1 @@ -146,20 +180,37 @@ def test_equivariance(): model = EnEquivariantNetworkBlock( node_feature_dim=x.shape[1], - edge_feature_dim=0, + edge_feature_dim=edge_feature_dim, pos_dim=pos.shape[1], hidden_dim=64, n_message_layers=2, n_update_layers=2, + use_velocity=use_velocity, ).eval() - h1, pos1 = model(edge_index=edge_index, x=x, pos=pos) - h2, pos2 = model( - edge_index=edge_index, x=x, pos=pos @ rotation.T + translation + # Manage inputs + vel = velocity if use_velocity else None + edge_attr = edge_attributes if edge_feature_dim > 0 else None + + # Transform inputs (no translation for velocity) + pos_rot = pos @ rotation.T + translation + vel_rot = vel @ rotation.T if use_velocity else vel + + # Get model outputs + out1 = model( + x=x, pos=pos, edge_index=edge_idx, edge_attr=edge_attr, vel=vel + ) + out2 = model( + x=x, pos=pos_rot, edge_index=edge_idx, edge_attr=edge_attr, vel=vel_rot ) - # Transform model output - pos1_transformed = (pos1 @ rotation.T) + translation + # Unpack outputs + h1, pos1, *other1 = out1 + h2, pos2, *other2 = out2 + if use_velocity: + vel1, vel2 = other1[0], other2[0] - assert torch.allclose(pos2, pos1_transformed, atol=1e-5) + assert torch.allclose(pos2, pos1 @ rotation.T + translation, atol=1e-5) assert torch.allclose(h1, h2, atol=1e-5) + if vel is not None: + assert torch.allclose(vel2, vel1 @ rotation.T, atol=1e-5) diff --git a/tests/test_messagepassing/test_equivariant_operator_block.py b/tests/test_messagepassing/test_equivariant_operator_block.py new file mode 100644 index 000000000..ad4f0509b --- /dev/null +++ b/tests/test_messagepassing/test_equivariant_operator_block.py @@ -0,0 +1,132 @@ +import pytest +import torch +from pina.model.block.message_passing import EquivariantGraphNeuralOperatorBlock + +# Data for testing. Shapes: (time, nodes, features) +x = torch.rand(5, 10, 4) +pos = torch.rand(5, 10, 3) +vel = torch.rand(5, 10, 3) + +# Edge index and attributes +edge_idx = torch.randint(0, 10, (2, 20)) +edge_attributes = torch.randn(20, 2) + + +@pytest.mark.parametrize("node_feature_dim", [1, 3]) +@pytest.mark.parametrize("edge_feature_dim", [0, 2]) +@pytest.mark.parametrize("pos_dim", [2, 3]) +@pytest.mark.parametrize("modes", [1, 5]) +def test_constructor(node_feature_dim, edge_feature_dim, pos_dim, modes): + + EquivariantGraphNeuralOperatorBlock( + node_feature_dim=node_feature_dim, + edge_feature_dim=edge_feature_dim, + pos_dim=pos_dim, + modes=modes, + ) + + # Should fail if modes is negative + with pytest.raises(AssertionError): + EquivariantGraphNeuralOperatorBlock( + node_feature_dim=node_feature_dim, + edge_feature_dim=edge_feature_dim, + pos_dim=pos_dim, + modes=-1, + ) + + +@pytest.mark.parametrize("modes", [1, 5]) +def test_forward(modes): + + model = EquivariantGraphNeuralOperatorBlock( + node_feature_dim=x.shape[2], + edge_feature_dim=edge_attributes.shape[1], + pos_dim=pos.shape[2], + modes=modes, + ) + + output_ = model( + x=x, + pos=pos, + vel=vel, + edge_index=edge_idx, + edge_attr=edge_attributes, + ) + + # Checks on output shapes + assert output_[0].shape == x.shape + assert output_[1].shape == pos.shape + assert output_[2].shape == vel.shape + + +@pytest.mark.parametrize("modes", [1, 5]) +def test_backward(modes): + + model = EquivariantGraphNeuralOperatorBlock( + node_feature_dim=x.shape[2], + edge_feature_dim=edge_attributes.shape[1], + pos_dim=pos.shape[2], + modes=modes, + ) + + output_ = model( + x=x.requires_grad_(), + pos=pos.requires_grad_(), + vel=vel.requires_grad_(), + edge_index=edge_idx, + edge_attr=edge_attributes.requires_grad_(), + ) + + # Checks on gradients + loss = sum(torch.mean(output_[i]) for i in range(len(output_))) + loss.backward() + assert x.grad.shape == x.shape + assert pos.grad.shape == pos.shape + assert vel.grad.shape == vel.shape + + +@pytest.mark.parametrize("modes", [1, 5]) +def test_equivariance(modes): + + # Random rotation + rotation = torch.linalg.qr(torch.rand(pos.shape[2], pos.shape[2])).Q + if torch.det(rotation) < 0: + rotation[:, 0] *= -1 + + # Random translation + translation = torch.rand(1, pos.shape[2]) + + model = EquivariantGraphNeuralOperatorBlock( + node_feature_dim=x.shape[2], + edge_feature_dim=edge_attributes.shape[1], + pos_dim=pos.shape[2], + modes=modes, + ).eval() + + # Transform inputs (no translation for velocity) + pos_rot = pos @ rotation.T + translation + vel_rot = vel @ rotation.T + + # Get model outputs + out1 = model( + x=x, + pos=pos, + vel=vel, + edge_index=edge_idx, + edge_attr=edge_attributes, + ) + out2 = model( + x=x, + pos=pos_rot, + vel=vel_rot, + edge_index=edge_idx, + edge_attr=edge_attributes, + ) + + # Unpack outputs + h1, pos1, vel1 = out1 + h2, pos2, vel2 = out2 + + assert torch.allclose(pos2, pos1 @ rotation.T + translation, atol=1e-5) + assert torch.allclose(vel2, vel1 @ rotation.T, atol=1e-5) + assert torch.allclose(h1, h2, atol=1e-5) diff --git a/tests/test_model/test_equivariant_graph_neural_operator.py b/tests/test_model/test_equivariant_graph_neural_operator.py new file mode 100644 index 000000000..c4c04840a --- /dev/null +++ b/tests/test_model/test_equivariant_graph_neural_operator.py @@ -0,0 +1,194 @@ +import pytest +import torch +import copy +from pina.model import EquivariantGraphNeuralOperator +from pina.graph import Graph + + +# Utility to create graphs +def make_graph(include_vel=True, use_edge_attr=True): + data = dict( + x=torch.rand(10, 4), + pos=torch.rand(10, 3), + edge_index=torch.randint(0, 10, (2, 20)), + edge_attr=torch.randn(20, 2) if use_edge_attr else None, + ) + if include_vel: + data["vel"] = torch.rand(10, 3) + return Graph(**data) + + +@pytest.mark.parametrize("n_egno_layers", [1, 3]) +@pytest.mark.parametrize("time_steps", [1, 3]) +@pytest.mark.parametrize("time_emb_dim", [4, 8]) +@pytest.mark.parametrize("max_time_idx", [10, 20]) +def test_constructor(n_egno_layers, time_steps, time_emb_dim, max_time_idx): + + # Create graph and model + graph = make_graph() + EquivariantGraphNeuralOperator( + n_egno_layers=n_egno_layers, + node_feature_dim=graph.x.shape[1], + edge_feature_dim=graph.edge_attr.shape[1], + pos_dim=graph.pos.shape[1], + modes=5, + time_steps=time_steps, + time_emb_dim=time_emb_dim, + max_time_idx=max_time_idx, + ) + + # Should fail if n_egno_layers is negative + with pytest.raises(AssertionError): + EquivariantGraphNeuralOperator( + n_egno_layers=-1, + node_feature_dim=graph.x.shape[1], + edge_feature_dim=graph.edge_attr.shape[1], + pos_dim=graph.pos.shape[1], + modes=5, + time_steps=time_steps, + time_emb_dim=time_emb_dim, + max_time_idx=max_time_idx, + ) + + # Should fail if time_steps is negative + with pytest.raises(AssertionError): + EquivariantGraphNeuralOperator( + n_egno_layers=n_egno_layers, + node_feature_dim=graph.x.shape[1], + edge_feature_dim=graph.edge_attr.shape[1], + pos_dim=graph.pos.shape[1], + modes=5, + time_steps=-1, + time_emb_dim=time_emb_dim, + max_time_idx=max_time_idx, + ) + + # Should fail if max_time_idx is negative + with pytest.raises(AssertionError): + EquivariantGraphNeuralOperator( + n_egno_layers=n_egno_layers, + node_feature_dim=graph.x.shape[1], + edge_feature_dim=graph.edge_attr.shape[1], + pos_dim=graph.pos.shape[1], + modes=5, + time_steps=time_steps, + time_emb_dim=time_emb_dim, + max_time_idx=-1, + ) + + # Should fail if time_emb_dim is negative + with pytest.raises(AssertionError): + EquivariantGraphNeuralOperator( + n_egno_layers=n_egno_layers, + node_feature_dim=graph.x.shape[1], + edge_feature_dim=graph.edge_attr.shape[1], + pos_dim=graph.pos.shape[1], + modes=5, + time_steps=time_steps, + time_emb_dim=-1, + max_time_idx=max_time_idx, + ) + + +@pytest.mark.parametrize("n_egno_layers", [1, 3]) +@pytest.mark.parametrize("time_steps", [1, 5]) +@pytest.mark.parametrize("modes", [1, 3, 10]) +@pytest.mark.parametrize("use_edge_attr", [True, False]) +def test_forward(n_egno_layers, time_steps, modes, use_edge_attr): + + # Create graph and model + graph = make_graph(use_edge_attr=use_edge_attr) + model = EquivariantGraphNeuralOperator( + n_egno_layers=n_egno_layers, + node_feature_dim=graph.x.shape[1], + edge_feature_dim=graph.edge_attr.shape[1] if use_edge_attr else 0, + pos_dim=graph.pos.shape[1], + modes=modes, + time_steps=time_steps, + ) + + # Checks on output shapes + output_ = model(graph) + assert output_.x.shape == (time_steps, *graph.x.shape) + assert output_.pos.shape == (time_steps, *graph.pos.shape) + assert output_.vel.shape == (time_steps, *graph.vel.shape) + + # Should fail graph has no vel attribute + with pytest.raises(ValueError): + graph_no_vel = make_graph(include_vel=False) + model(graph_no_vel) + + +@pytest.mark.parametrize("n_egno_layers", [1, 3]) +@pytest.mark.parametrize("time_steps", [1, 5]) +@pytest.mark.parametrize("modes", [1, 3, 10]) +@pytest.mark.parametrize("use_edge_attr", [True, False]) +def test_backward(n_egno_layers, time_steps, modes, use_edge_attr): + + # Create graph and model + graph = make_graph(use_edge_attr=use_edge_attr) + model = EquivariantGraphNeuralOperator( + n_egno_layers=n_egno_layers, + node_feature_dim=graph.x.shape[1], + edge_feature_dim=graph.edge_attr.shape[1] if use_edge_attr else 0, + pos_dim=graph.pos.shape[1], + modes=modes, + time_steps=time_steps, + ) + + # Set requires_grad and perform forward pass + graph.x.requires_grad_() + graph.pos.requires_grad_() + graph.vel.requires_grad_() + out = model(graph) + + # Checks on gradients + loss = torch.mean(out.x) + torch.mean(out.pos) + torch.mean(out.vel) + loss.backward() + assert graph.x.grad.shape == graph.x.shape + assert graph.pos.grad.shape == graph.pos.shape + assert graph.vel.grad.shape == graph.vel.shape + + +@pytest.mark.parametrize("n_egno_layers", [1, 3]) +@pytest.mark.parametrize("time_steps", [1, 5]) +@pytest.mark.parametrize("modes", [1, 3, 10]) +@pytest.mark.parametrize("use_edge_attr", [True, False]) +def test_equivariance(n_egno_layers, time_steps, modes, use_edge_attr): + + graph = make_graph(use_edge_attr=use_edge_attr) + model = EquivariantGraphNeuralOperator( + n_egno_layers=n_egno_layers, + node_feature_dim=graph.x.shape[1], + edge_feature_dim=graph.edge_attr.shape[1] if use_edge_attr else 0, + pos_dim=graph.pos.shape[1], + modes=modes, + time_steps=time_steps, + ).eval() + + # Random rotation + rotation = torch.linalg.qr( + torch.rand(graph.pos.shape[1], graph.pos.shape[1]) + ).Q + if torch.det(rotation) < 0: + rotation[:, 0] *= -1 + + # Random translation + translation = torch.rand(1, graph.pos.shape[1]) + + # Transform graph (no translation for velocity) + graph_rot = copy.deepcopy(graph) + graph_rot.pos = graph.pos @ rotation.T + translation + graph_rot.vel = graph.vel @ rotation.T + + # Get model outputs + out1 = model(graph) + out2 = model(graph_rot) + + # Unpack outputs + h1, pos1, vel1 = out1.x, out1.pos, out1.vel + h2, pos2, vel2 = out2.x, out2.pos, out2.vel + + assert torch.allclose(pos2, pos1 @ rotation.T + translation, atol=1e-5) + assert torch.allclose(vel2, vel1 @ rotation.T, atol=1e-5) + assert torch.allclose(h1, h2, atol=1e-5) diff --git a/tests/test_model/test_sindy.py b/tests/test_model/test_sindy.py new file mode 100644 index 000000000..223c4eba2 --- /dev/null +++ b/tests/test_model/test_sindy.py @@ -0,0 +1,55 @@ +import torch +import pytest +from pina.model import SINDy + +# Define a simple library of candidate functions and some test data +library = [lambda x: torch.pow(x, 2), lambda x: torch.sin(x)] + + +@pytest.mark.parametrize("data", [torch.rand((20, 1)), torch.rand((5, 20, 1))]) +def test_constructor(data): + SINDy(library, data.shape[-1]) + + # Should fail if output_dimension is not a positive integer + with pytest.raises(AssertionError): + SINDy(library, "not_int") + with pytest.raises(AssertionError): + SINDy(library, -1) + + # Should fail if library is not a list + with pytest.raises(ValueError): + SINDy(lambda x: torch.pow(x, 2), 3) + + # Should fail if library is not a list of callables + with pytest.raises(ValueError): + SINDy([1, 2, 3], 3) + + +@pytest.mark.parametrize("data", [torch.rand((20, 1)), torch.rand((5, 20, 1))]) +def test_forward(data): + + # Define model + model = SINDy(library, data.shape[-1]) + with torch.no_grad(): + model.coefficients.data.fill_(1.0) + + # Evaluate model + output_ = model(data) + vals = data.pow(2) + torch.sin(data) + + print(data.shape, output_.shape, vals.shape) + + assert output_.shape == data.shape + assert torch.allclose(output_, vals, atol=1e-6, rtol=1e-6) + + +@pytest.mark.parametrize("data", [torch.rand((20, 1)), torch.rand((5, 20, 1))]) +def test_backward(data): + + # Define and evaluate model + model = SINDy(library, data.shape[-1]) + output_ = model(data.requires_grad_()) + + loss = output_.mean() + loss.backward() + assert data.grad.shape == data.shape diff --git a/tests/test_model/test_spline.py b/tests/test_model/test_spline.py index d38b1610b..d22de9f26 100644 --- a/tests/test_model/test_spline.py +++ b/tests/test_model/test_spline.py @@ -1,81 +1,175 @@ import torch import pytest - +from scipy.interpolate import BSpline from pina.model import Spline +from pina import LabelTensor -data = torch.rand((20, 3)) -input_vars = 3 -output_vars = 4 -valid_args = [ - { - "knots": torch.tensor([0.0, 0.0, 0.0, 1.0, 2.0, 3.0, 3.0, 3.0]), - "control_points": torch.tensor([0.0, 0.0, 1.0, 0.0, 0.0]), - "order": 3, - }, - { - "knots": torch.tensor( - [-2.0, -2.0, -2.0, -2.0, -1.0, 0.0, 1.0, 2.0, 2.0, 2.0, 2.0] - ), - "control_points": torch.tensor([0.0, 0.0, 0.0, 6.0, 0.0, 0.0, 0.0]), - "order": 4, - }, - # {'control_points': {'n': 5, 'dim': 1}, 'order': 2}, - # {'control_points': {'n': 7, 'dim': 1}, 'order': 3} +# Utility quantities for testing +order = torch.randint(1, 8, (1,)).item() +n_ctrl_pts = torch.randint(order, order + 5, (1,)).item() +n_knots = order + n_ctrl_pts + +# Input tensor +points = [ + LabelTensor(torch.rand(100, 1), ["x"]), + LabelTensor(torch.rand(2, 100, 1), ["x"]), ] -def scipy_check(model, x, y): - from scipy.interpolate._bsplines import BSpline - import numpy as np +# Function to compare with scipy implementation +def check_scipy_spline(model, x, output_): - spline = BSpline( + # Define scipy spline + scipy_spline = BSpline( t=model.knots.detach().numpy(), c=model.control_points.detach().numpy(), k=model.order - 1, ) - y_scipy = spline(x).flatten() - y = y.detach().numpy() - np.testing.assert_allclose(y, y_scipy, atol=1e-5) + + # Compare outputs + torch.allclose( + output_, + torch.tensor(scipy_spline(x), dtype=output_.dtype), + atol=1e-5, + rtol=1e-5, + ) + + +# Define all possible combinations of valid arguments for Spline class +valid_args = [ + { + "order": order, + "control_points": torch.rand(n_ctrl_pts), + "knots": torch.linspace(0, 1, n_knots), + }, + { + "order": order, + "control_points": torch.rand(n_ctrl_pts), + "knots": {"n": n_knots, "min": 0, "max": 1, "mode": "auto"}, + }, + { + "order": order, + "control_points": torch.rand(n_ctrl_pts), + "knots": {"n": n_knots, "min": 0, "max": 1, "mode": "uniform"}, + }, + { + "order": order, + "control_points": None, + "knots": torch.linspace(0, 1, n_knots), + }, + { + "order": order, + "control_points": None, + "knots": {"n": n_knots, "min": 0, "max": 1, "mode": "auto"}, + }, + { + "order": order, + "control_points": None, + "knots": {"n": n_knots, "min": 0, "max": 1, "mode": "uniform"}, + }, + { + "order": order, + "control_points": torch.rand(n_ctrl_pts), + "knots": None, + }, +] @pytest.mark.parametrize("args", valid_args) def test_constructor(args): Spline(**args) + # Should fail if order is not a positive integer + with pytest.raises(AssertionError): + Spline( + order=-1, control_points=args["control_points"], knots=args["knots"] + ) + + # Should fail if control_points is not None or a torch.Tensor + with pytest.raises(ValueError): + Spline( + order=args["order"], control_points=[1, 2, 3], knots=args["knots"] + ) + + # Should fail if knots is not None, a torch.Tensor, or a dict + with pytest.raises(ValueError): + Spline( + order=args["order"], control_points=args["control_points"], knots=5 + ) + + # Should fail if both knots and control_points are None + with pytest.raises(ValueError): + Spline(order=args["order"], control_points=None, knots=None) + + # Should fail if knots is not one-dimensional + with pytest.raises(ValueError): + Spline( + order=args["order"], + control_points=args["control_points"], + knots=torch.rand(n_knots, 4), + ) + + # Should fail if control_points is not one-dimensional + with pytest.raises(ValueError): + Spline( + order=args["order"], + control_points=torch.rand(n_ctrl_pts, 4), + knots=args["knots"], + ) + + # Should fail if the number of knots != order + number of control points + # If control points are None, they are initialized to fulfill this condition + if args["control_points"] is not None: + with pytest.raises(ValueError): + Spline( + order=args["order"], + control_points=args["control_points"], + knots=torch.linspace(0, 1, n_knots + 1), + ) + + # Should fail if the knot dict is missing required keys + with pytest.raises(ValueError): + Spline( + order=args["order"], + control_points=args["control_points"], + knots={"n": n_knots, "min": 0, "max": 1}, + ) -def test_constructor_wrong(): + # Should fail if the knot dict has invalid 'mode' key with pytest.raises(ValueError): - Spline() + Spline( + order=args["order"], + control_points=args["control_points"], + knots={"n": n_knots, "min": 0, "max": 1, "mode": "invalid"}, + ) @pytest.mark.parametrize("args", valid_args) -def test_forward(args): - min_x = args["knots"][0] - max_x = args["knots"][-1] - xi = torch.linspace(min_x, max_x, 1000) +@pytest.mark.parametrize("pts", points) +def test_forward(args, pts): + + # Define the model model = Spline(**args) - yi = model(xi).squeeze() - scipy_check(model, xi, yi) - return + + # Evaluate the model + output_ = model(pts) + assert output_.shape == pts.shape + + # Compare with scipy implementation only for interpolant knots (mode: auto) + if isinstance(args["knots"], dict) and args["knots"]["mode"] == "auto": + check_scipy_spline(model, pts, output_) @pytest.mark.parametrize("args", valid_args) -def test_backward(args): - min_x = args["knots"][0] - max_x = args["knots"][-1] - xi = torch.linspace(min_x, max_x, 100) +@pytest.mark.parametrize("pts", points) +def test_backward(args, pts): + + # Define the model model = Spline(**args) - yi = model(xi) - fake_loss = torch.sum(yi) - assert model.control_points.grad is None - fake_loss.backward() - assert model.control_points.grad is not None - - # dim_in, dim_out = 3, 2 - # fnn = FeedForward(dim_in, dim_out) - # data.requires_grad = True - # output_ = fnn(data) - # l=torch.mean(output_) - # l.backward() - # assert data._grad.shape == torch.Size([20,3]) + + # Evaluate the model + output_ = model(pts) + loss = torch.mean(output_) + loss.backward() + assert model.control_points.grad.shape == model.control_points.shape diff --git a/tests/test_model/test_spline_surface.py b/tests/test_model/test_spline_surface.py new file mode 100644 index 000000000..feab587b5 --- /dev/null +++ b/tests/test_model/test_spline_surface.py @@ -0,0 +1,180 @@ +import torch +import random +import pytest +from pina.model import SplineSurface +from pina import LabelTensor + + +# Utility quantities for testing +orders = [random.randint(1, 8) for _ in range(2)] +n_ctrl_pts = random.randint(max(orders), max(orders) + 5) +n_knots = [orders[i] + n_ctrl_pts for i in range(2)] + +# Input tensor +points = [ + LabelTensor(torch.rand(100, 2), ["x", "y"]), + LabelTensor(torch.rand(2, 100, 2), ["x", "y"]), +] + + +@pytest.mark.parametrize( + "knots_u", + [ + torch.rand(n_knots[0]), + {"n": n_knots[0], "min": 0, "max": 1, "mode": "auto"}, + {"n": n_knots[0], "min": 0, "max": 1, "mode": "uniform"}, + None, + ], +) +@pytest.mark.parametrize( + "knots_v", + [ + torch.rand(n_knots[1]), + {"n": n_knots[1], "min": 0, "max": 1, "mode": "auto"}, + {"n": n_knots[1], "min": 0, "max": 1, "mode": "uniform"}, + None, + ], +) +@pytest.mark.parametrize( + "control_points", [torch.rand(n_ctrl_pts, n_ctrl_pts), None] +) +def test_constructor(knots_u, knots_v, control_points): + + # Skip if knots_u, knots_v, and control_points are all None + if (knots_u is None or knots_v is None) and control_points is None: + return + + SplineSurface( + orders=orders, + knots_u=knots_u, + knots_v=knots_v, + control_points=control_points, + ) + + # Should fail if orders is not list of two elements + with pytest.raises(ValueError): + SplineSurface( + orders=[orders[0]], + knots_u=knots_u, + knots_v=knots_v, + control_points=control_points, + ) + + # Should fail if both knots and control_points are None + with pytest.raises(ValueError): + SplineSurface( + orders=orders, + knots_u=None, + knots_v=None, + control_points=None, + ) + + # Should fail if control_points is not a torch.Tensor when provided + with pytest.raises(ValueError): + SplineSurface( + orders=orders, + knots_u=knots_u, + knots_v=knots_v, + control_points=[[0.0] * n_ctrl_pts] * n_ctrl_pts, + ) + + # Should fail if control_points is not of the correct shape when provided + # It assumes that at least one among knots_u and knots_v is not None + if knots_u is not None or knots_v is not None: + with pytest.raises(ValueError): + SplineSurface( + orders=orders, + knots_u=knots_u, + knots_v=knots_v, + control_points=torch.rand(n_ctrl_pts + 1, n_ctrl_pts + 1), + ) + + # Should fail if there are not enough knots_u to define the control points + with pytest.raises(ValueError): + SplineSurface( + orders=orders, + knots_u=torch.linspace(0, 1, orders[0]), + knots_v=knots_v, + control_points=None, + ) + + # Should fail if there are not enough knots_v to define the control points + with pytest.raises(ValueError): + SplineSurface( + orders=orders, + knots_u=knots_u, + knots_v=torch.linspace(0, 1, orders[1]), + control_points=None, + ) + + +@pytest.mark.parametrize( + "knots_u", + [ + torch.rand(n_knots[0]), + {"n": n_knots[0], "min": 0, "max": 1, "mode": "auto"}, + {"n": n_knots[0], "min": 0, "max": 1, "mode": "uniform"}, + ], +) +@pytest.mark.parametrize( + "knots_v", + [ + torch.rand(n_knots[1]), + {"n": n_knots[1], "min": 0, "max": 1, "mode": "auto"}, + {"n": n_knots[1], "min": 0, "max": 1, "mode": "uniform"}, + ], +) +@pytest.mark.parametrize( + "control_points", [torch.rand(n_ctrl_pts, n_ctrl_pts), None] +) +@pytest.mark.parametrize("pts", points) +def test_forward(knots_u, knots_v, control_points, pts): + + # Define the model + model = SplineSurface( + orders=orders, + knots_u=knots_u, + knots_v=knots_v, + control_points=control_points, + ) + + # Evaluate the model + output_ = model(pts) + assert output_.shape == (*pts.shape[:-1], 1) + + +@pytest.mark.parametrize( + "knots_u", + [ + torch.rand(n_knots[0]), + {"n": n_knots[0], "min": 0, "max": 1, "mode": "auto"}, + {"n": n_knots[0], "min": 0, "max": 1, "mode": "uniform"}, + ], +) +@pytest.mark.parametrize( + "knots_v", + [ + torch.rand(n_knots[1]), + {"n": n_knots[1], "min": 0, "max": 1, "mode": "auto"}, + {"n": n_knots[1], "min": 0, "max": 1, "mode": "uniform"}, + ], +) +@pytest.mark.parametrize( + "control_points", [torch.rand(n_ctrl_pts, n_ctrl_pts), None] +) +@pytest.mark.parametrize("pts", points) +def test_backward(knots_u, knots_v, control_points, pts): + + # Define the model + model = SplineSurface( + orders=orders, + knots_u=knots_u, + knots_v=knots_v, + control_points=control_points, + ) + + # Evaluate the model + output_ = model(pts) + loss = torch.mean(output_) + loss.backward() + assert model.control_points.grad.shape == model.control_points.shape diff --git a/tutorials/README.md b/tutorials/README.md index dfbb65116..62150ee67 100644 --- a/tutorials/README.md +++ b/tutorials/README.md @@ -43,6 +43,7 @@ Solving the Kuramoto–Sivashinsky Equation with Averaging Neural Operator |[[.i Introductory Tutorial: Supervised Learning with PINA |[[.ipynb](tutorial20/tutorial.ipynb),[.py](tutorial20/tutorial.py),[.html](http://mathlab.github.io/PINA/tutorial20/tutorial.html)]| Chemical Properties Prediction with Graph Neural Networks |[[.ipynb](tutorial15/tutorial.ipynb),[.py](tutorial15/tutorial.py),[.html](http://mathlab.github.io/PINA/tutorial15/tutorial.html)]| Reduced Order Model with Graph Neural Networks for Unstructured Domains| [[.ipynb](tutorial22/tutorial.ipynb),[.py](tutorial22/tutorial.py),[.html](http://mathlab.github.io/PINA/tutorial22/tutorial.html)]| +Data-driven System Identification with SINDy| [[.ipynb](tutorial23/tutorial.ipynb),[.py](tutorial23/tutorial.py),[.html](http://mathlab.github.io/PINA/tutorial23/tutorial.html)]| Unstructured Convolutional Autoencoders with Continuous Convolution |[[.ipynb](tutorial4/tutorial.ipynb),[.py](tutorial4/tutorial.py),[.html](http://mathlab.github.io/PINA/tutorial4/tutorial.html)]| Reduced Order Modeling with POD-RBF and POD-NN Approaches for Fluid Dynamics| [[.ipynb](tutorial8/tutorial.ipynb),[.py](tutorial8/tutorial.py),[.html](http://mathlab.github.io/PINA/tutorial8/tutorial.html)]| diff --git a/tutorials/tutorial17/tutorial.ipynb b/tutorials/tutorial17/tutorial.ipynb index 6d36a25f8..fa82f9a7f 100644 --- a/tutorials/tutorial17/tutorial.ipynb +++ b/tutorials/tutorial17/tutorial.ipynb @@ -114,7 +114,7 @@ "\n", "from pina import Condition, LabelTensor\n", "from pina.problem import AbstractProblem\n", - "from pina.geometry import CartesianDomain" + "from pina.domain import CartesianDomain" ] }, { @@ -136,7 +136,7 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": null, "id": "014bbd86", "metadata": {}, "outputs": [], @@ -152,7 +152,7 @@ "\n", " output_variables = [\"y\"]\n", " input_variables = [\"x\"]\n", - " conditions = {\"data\": Condition(input_points=x, output_points=y)}\n", + " conditions = {\"data\": Condition(input=x, target=y)}\n", "\n", "\n", "problem = BayesianProblem()\n", @@ -183,37 +183,10 @@ }, { "cell_type": "code", - "execution_count": 63, + "execution_count": null, "id": "6f25d3a6", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "The Label Tensor object, a very short introduction... \n", - "\n", - "1: {'dof': ['a', 'b', 'c', 'd'], 'name': 1}\n", - "\n", - "tensor([[0.7630, 0.1998, 0.3470, 0.4409],\n", - " [0.7179, 0.5710, 0.2510, 0.3984],\n", - " [0.0724, 0.5714, 0.9199, 0.7571]]) \n", - "\n", - "Torch methods can be used, label_tensor.shape=torch.Size([3, 4])\n", - "also label_tensor.requires_grad=False \n", - "\n", - "But we have labels as well, e.g. label_tensor.labels=['a', 'b', 'c', 'd']\n", - "And we can slice with labels: \n", - " label_tensor[\"a\"]=LabelTensor([[0.7630],\n", - " [0.7179],\n", - " [0.0724]])\n", - "Similarly to: \n", - " label_tensor[:, 0]=LabelTensor([[0.7630],\n", - " [0.7179],\n", - " [0.0724]])\n" - ] - } - ], + "outputs": [], "source": [ "# EXTRA - on the use of LabelTensor\n", "\n", @@ -265,53 +238,10 @@ }, { "cell_type": "code", - "execution_count": 64, + "execution_count": null, "id": "5388aaaa", "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "GPU available: True (mps), used: False\n", - "TPU available: False, using: 0 TPU cores\n", - "HPU available: False, using: 0 HPUs\n", - "\n", - " | Name | Type | Params | Mode \n", - "----------------------------------------------------\n", - "0 | _pina_models | ModuleList | 301 | train\n", - "1 | _loss_fn | MSELoss | 0 | train\n", - "----------------------------------------------------\n", - "301 Trainable params\n", - "0 Non-trainable params\n", - "301 Total params\n", - "0.001 Total estimated model params size (MB)\n", - "8 Modules in train mode\n", - "0 Modules in eval mode\n" - ] - }, - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "73747bd57cac432eb8dddd5254be755c", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "Training: | | 0/? [00:00" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "x_test = LabelTensor(torch.linspace(-4, 4, 100).reshape(-1, 1), \"x\")\n", "y_test = torch.stack([solver(x_test) for _ in range(1000)], dim=0)\n", @@ -437,7 +356,7 @@ }, { "cell_type": "code", - "execution_count": 66, + "execution_count": null, "id": "02518706", "metadata": {}, "outputs": [], @@ -489,21 +408,10 @@ }, { "cell_type": "code", - "execution_count": 67, + "execution_count": null, "id": "47459922", "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAp4AAAF2CAYAAADHiZjhAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuNCwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8ekN5oAAAACXBIWXMAAA9hAAAPYQGoP6dpAAEAAElEQVR4nOzdCbx113w//o0SdNAa2tKqmV/NGvOYEGLWSmImA4m5iCEJipiSoCpEEUMSJOaYghBJJKmpEtKWoqU6qtJJlb8oef6v91k+uevZOfc+997nDufe+/28Xueee/bZe6211z77uz/rO15i27Zt24ZCoVAoFAqFQmGVccnV7qBQKBQKhUKhUIAinoVCoVAoFAqFNUERz0KhUCgUCoXCmqCIZ6FQKBQKhUJhTVDEs1AoFAqFQqGwJijiWSgUCoVCoVBYExTxLBQKhUKhUCisCYp4FgqFQqFQKBTWBEU8C4VCoVAoFAprgiKehXXB3//93w+XuMQlhuOPP369h7JhsN9++w3XvOY113sYhUJhBlEydWGU/JwdFPHcYCBUCJdzzz136ve77bbbcOMb33jNx7WZhNMv/dIvLevYv/7rvx5e8IIXTB4AhUJhY6Bk6urLVPOb1y/8wi8MV7/61YeHPOQhE5lZ2Hr4hfUeQKGwWUCIHn744ZMH1WqsrN/4xjcOF1544Yq3WygUCquJXXbZZXjTm940+f+nP/3p8M1vfnN4/etfP5x66qkTuXm1q11tvYdYWEMU8SysCH74wx8Ov/iLv7jew9jUc3vpS196xdpEYH/yk58Ml73sZVeszUKhsHLYTDKVlvMRj3jEdttue9vbDve9732Hj3zkI8OBBx640338+Mc/Hi5zmcsMl7zk6htyt23bNunvcpe73Kr3tRlRpvYtACvMF73oRcN1rnOdycqTNu7Zz372cMEFF2y3HzMIU/EY9mcuGZumzjrrrOEJT3jC8Ou//uvDb//2b1/0/Wtf+9rh2te+9uSmvPWtbz2cc845Ey2g10L4y7/8y0k/jkWIfvM3f3M44IADhv/4j//Ybr8f/OAHw1Of+tTJuJyP/u9+97sPX/ziFy/a52//9m+Hvfbaa9KGtoyPaef73//+kudPPwTkn/3Zn03OR3vG+Na3vnW7Odlnn30m/+++++4XmZU+9alPXbTPxz72seFOd7rT5GHyy7/8y8N97nOf4Stf+cpUUz+NwL3vfe/Jfg9/+MMv+m6sSfVwevrTnz4xXZmLG9zgBsMrXvGKiWDsYSxPetKThhNPPHG40Y1uNNmXtqFQKCwdJVN3TqaCdkJKe/zd3/3dRJZe8YpXHC5/+ctPCCpy2oNcNV/vfOc7h+c+97nDb/3Wb032/Z//+Z/J9x/4wAcm7hHG6f3973//vAvwV73qVROZaN/f+I3fGB772McO//Vf/zX1GfDxj398uOUtbzm5Dm94wxuWdd6F0nhuWLjZ//3f//1i2//v//7vYtse85jHDCeccMKw9957T0jK5z//+eGII44YvvrVr857Qy4GBORVrnKV4XnPe96EAMHrXve6CcFBsJ72tKdN/B1///d/f/i1X/u17QTpNJx22mkTobP//vtPhBJSduyxx07eP/e5z00EDTzucY8b3vve9076ueENbzgRokih8/m93/u9iSZvzz33nDwEnvzkJ0/a+pd/+ZfhlFNOGf77v/97uMIVrrDkc/3GN74xmb9HP/rRw7777ju85S1vmQj0XXfddSK07nznOw9/+Id/OLz61a+ePIB+93d/d3Jc3t/2trdNjjOuo446avjRj340mas73vGOw5e+9KXtCKWHmv18h0QSqNOAXN7//vcfzjzzzMm4bn7zm08E4zOf+czJ+f7Jn/zJdvufccYZw7vf/e7JvF35ylcuR/tCoUPJ1NWVqZnbn/3sZ5MxHXLIIcOVrnSlCaEL/u3f/m24/e1vP5GP5KnvzTM5Z3x/8Ad/sF2byD8t5zOe8YzJ2Pz/iU98YkKQnYdr4lyc/7S5QjKRft/r71vf+tZwzDHHTGTypz/96e2sTF//+teHhz70oZNjaGgt8gvLxLbChsJxxx1HlbXg60Y3utFF+59//vmTbY95zGO2a+cZz3jGZPsZZ5xx0Tafn//851+sz2tc4xrb9t1334uN4Y53vOO2n/70pxdtv+CCC7Zd6UpX2narW91q2//93/9dtP3444+f7H+Xu9zlom3f+ta3Jtu0FfzoRz+6WN/veMc7JvudffbZF227whWusO2JT3zivHP0pS99aXLMe97znm1LhfP8xV/8xYud/3gM3/3ud7ftsssu257+9KdftE1/9jvzzDO3O/4HP/jBtl/91V/dduCBB263/Tvf+c7kXPrt+tfGoYceOnVsxhJ84AMfmOz74he/eLv99t57722XuMQltn3jG9+4aJv9LnnJS277yle+ssQZKRQ2N0qmrr5MnTanv/Vbv7XtvPPO227fpz71qZPvzjnnnO3k57Wuda1t17zmNbf97Gc/m2wjY+137Wtf+2LnePOb33zbVa961W3//d//fdG2T3ziE5P9e/mpD9tOPPHE7Y4/9dRTL7Y9zwDfFXYeZWrfoGB6sZodv25605tut99HP/rRyfvBBx+83XardBibMJYCq75LXepSF30WFWp1aXtvPmEqtjrfEXp/Gf4zVsjMLNCbfH71V391omH49re/PbWdrL5p/6ycVwJWzzQOAa2EFa+V+47gutAKWC07p7zM3W1uc5uJxnKMxz/+8Tts17XVhpX6+Np65jHt97jLXe4yOY9CoXBxlExdPZnKjJ351AYzNZci7kR/8zd/s93cciVg7Qnsd9BBB000veMoeFak/hz/9V//dTj//PMn23stLLeBsex7z3veM9nHd71cZsXS51guX+ta15pofQs7jzK1b1C4OfmajEEY9eaif/iHf5g4W1/3utfdbj+mEsLG98uFG7FH2hr3RWAuxqz7n//5n5OocH473/3ud7f7rvcjetnLXjYRLPwaCQnC61GPetTEjynj8lB45StfOfFpRBiZaji3L8fMDr/zO78zda7HvkDTwDcK7nrXu079/ld+5VcuNl87MqFlvkWD8gPtEfP++NqOr1ehUJhDydTVk6nI9B577LHdNn1c73rXGw477LDhfe9730XnazE+Ri/T+tRW882XdsegKBj7rJoD/qzTMJ6vkp8rhyKeWwTx5VkO+ORMw0pH9D3oQQ8aPvOZz0x8FPkrWnVy/r7nPe+5XRoh+xF8fKn487z85S+f+E2efPLJw73uda/JPn/8x3888cH84Ac/ONmHVpC/D7+mxZC6MXotRI9xEM80ZOz8PONQ32PsXM+5fzUiMysCs1BYOZRM3TmZ6hhk8Oyzz172+e3MfDl/pBORngZWrZXqq7A9inhuclzjGteY3GBWd1k1xomb+df3/creth6cypkvFttXAnFEdvfBMswkY5NVD5rD008/fbI651g/1haOcdWrXnXiiO9lZcoB/iUveclFQhJucpObTF6iHgnfO9zhDpPccS9+8YuHtXwQiXwFQm686t8ZmO9PfvKTk4jUXuv5ta997aLvC4XCyqJk6srJVOfxv//7v9udryCeMRYr0/L9tHMct0suk5/OoUjl2qJ8PDc5mDNAyogeTCYgpU9/I45XnyIg51udj8FMJQpRonMCJbCi3JFJOhrFsQZxPG5jGafvQOiYnJPKREqNvn8gLGkRx+lOVhLJuTd+0PALYk5/6UtfOjVC9nvf+96yr635EIXZQzQ7Etw/MAqFwsqgZOrKyFS+ncjgzW52s+3m9s///M+Hz372sxdtE91vzrgW7MhHHXmm2RUJ358T39Kxfygtr3MXGT+Gcx3L8cLKoTSemxxuar47blw3kgATN7YbU0qOfhUtRYi0GlJRcLj+i7/4i4kjuNQ7i4FUFnLWSbfBn9GNbVUuXQUBvJBpCjGTkoivEXImLxtzjvQWPWj3mGikMXFuTEdWrV/4whcmpqCkDZIWRC6461//+hMhwsxNEDu31QKBpw8mKkKPydw8EOJSojzykY+caBHkvmPG+cd//MdJIIIV95g8Lgb3u9/9JtfvOc95zmSezYc5YwqTky+a1kKhsHIombp0mWr/t7/97ZP/aYudA02p/5///OdftN+hhx46vOMd75gsmpny5fI0r8bMD3QxLkjM/8i/ACU5S/m5vuY1r5mkveu1q66b1Ej2F5B0j3vcY5I+ibZU4NHRRx89mZPCKmAFIuMLa4ik3fjCF74w9XvpNfrUHyANx+GHHz5JSXHpS19629WvfvVthx122LYf//jH2+0nVcUhhxyy7cpXvvK2y1/+8tv23HPPSUqe+VJ/zDeGV7/61ZNjpBu69a1vve3Tn/70tl133XXbPe95zwVTf/zzP//ztj/4gz+YpB6S3mOfffbZ9u1vf3u7lCTSizzzmc/cdrOb3WzbL//yL09SH/n/T//0Ty9q5+/+7u+2HXDAAduuc53rbLvsZS+77YpXvOK23XfffdsnP/nJZadTus997jN1rvt0JvDGN75xkuLjUpe61MVSK/nfnDo34zK+/fbbb9u55567YP/9d306kKQaedrTnrbtale72uTaXu9619v28pe/fNuFF1643X7GslC6lEJhq6Jk6urL1HEqpV/5lV/Zdre73W3q8d/85jcnKeGMWV/O95RTTtlun6RTmi+90/ve975tv/u7vzuZrxve8IbbTj755KnyE4499tjJXF7ucpebnP9NbnKTbc961rMm87SjZ0BhebiEP6tBaAuFwKqWhu+BD3zgxGRUKBQKheWj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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "plt.figure(figsize=(8, 4))\n", "plt.subplot(1, 2, 1)\n", @@ -541,7 +449,7 @@ }, { "cell_type": "code", - "execution_count": 69, + "execution_count": null, "id": "e1eb5a09", "metadata": {}, "outputs": [], @@ -594,22 +502,10 @@ }, { "cell_type": "code", - "execution_count": 70, + "execution_count": null, "id": "a95bb250", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Points are not automatically sampled, you can see this by:\n", - " poisson_problem.are_all_domains_discretised=False\n", - "\n", - "But you can easily sample by running .discretise_domain:\n", - " poisson_problem.are_all_domains_discretised=True\n" - ] - } - ], + "outputs": [], "source": [ "print(\"Points are not automatically sampled, you can see this by:\")\n", "print(f\" {poisson_problem.are_all_domains_discretised=}\\n\")\n", @@ -637,7 +533,7 @@ }, { "cell_type": "code", - "execution_count": 72, + "execution_count": null, "id": "b893232b", "metadata": {}, "outputs": [], @@ -687,41 +583,10 @@ }, { "cell_type": "code", - "execution_count": 73, + "execution_count": null, "id": "0f135cc4", "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "GPU available: True (mps), used: False\n", - "TPU available: False, using: 0 TPU cores\n", - "HPU available: False, using: 0 HPUs\n" - ] - }, - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "2e865b123dbb4f39bef00e0501eb6a61", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "Training: | | 0/? [00:00" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# sample points in the domain. remember to set requires_grad!\n", "pts = poisson_problem.spatial_domain.sample(1000).requires_grad_(True)\n", @@ -832,7 +686,7 @@ ], "metadata": { "kernelspec": { - "display_name": "pina", + "display_name": "deep", "language": "python", "name": "python3" }, @@ -846,7 +700,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.21" + "version": "3.12.11" } }, "nbformat": 4, diff --git a/tutorials/tutorial17/tutorial.py b/tutorials/tutorial17/tutorial.py index a1cd74aea..dcd50c406 100644 --- a/tutorials/tutorial17/tutorial.py +++ b/tutorials/tutorial17/tutorial.py @@ -2,80 +2,80 @@ # coding: utf-8 # # Tutorial: Introductory Tutorial: A Beginner’s Guide to PINA -# +# # [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/mathLab/PINA/blob/master/tutorials/tutorial17/tutorial.ipynb) -# +# #

# PINA logo #

-# -# +# +# # Welcome to **PINA**! -# +# # PINA [1] is an open-source Python library designed for **Scientific Machine Learning (SciML)** tasks, particularly involving: -# +# # - **Physics-Informed Neural Networks (PINNs)** # - **Neural Operators (NOs)** # - **Reduced Order Models (ROMs)** # - **Graph Neural Networks (GNNs)** # - ... -# +# # Built on **PyTorch**, **PyTorch Lightning**, and **PyTorch Geometric**, it provides a **user-friendly, intuitive interface** for formulating and solving differential problems using neural networks. -# +# # This tutorial offers a **step-by-step guide** to using PINA—starting from basic to advanced techniques—enabling users to tackle a broad spectrum of differential problems with minimal code. -# -# -# +# +# +# -# ## The PINA Workflow -# +# ## The PINA Workflow +# #

# PINA Workflow #

-# +# # Solving a differential problem in **PINA** involves four main steps: -# +# # 1. ***Problem & Data*** -# Define the mathematical problem and its physical constraints using PINA’s base classes: +# Define the mathematical problem and its physical constraints using PINA’s base classes: # - `AbstractProblem` # - `SpatialProblem` -# - `InverseProblem` +# - `InverseProblem` # - ... -# +# # Then prepare inputs by discretizing the domain or importing numerical data. PINA provides essential tools like the `Conditions` class and the `pina.domain` module to facilitate domain sampling and ensure that the input data aligns with the problem's requirements. -# +# # > **👉 We have a dedicated [tutorial](https://mathlab.github.io/PINA/tutorial16/tutorial.html) to teach how to build a Problem from scratch — have a look if you're interested!** -# -# 2. ***Model Design*** +# +# 2. ***Model Design*** # Build neural network models as **PyTorch modules**. For graph-structured data, use **PyTorch Geometric** to build Graph Neural Networks. You can also import models from `pina.model` module! -# -# 3. ***Solver Selection*** +# +# 3. ***Solver Selection*** # Choose and configure a solver to optimize your model. Options include: # - **Supervised solvers**: `SupervisedSolver`, `ReducedOrderModelSolver` # - **Physics-informed solvers**: `PINN` and (many) variants -# - **Generative solvers**: `GAROM` +# - **Generative solvers**: `GAROM` # Solvers can be used out-of-the-box, extended, or fully customized. -# -# 4. ***Training*** +# +# 4. ***Training*** # Train your model using the `Trainer` class (built on **PyTorch Lightning**), which enables scalable and efficient training with advanced features. -# -# +# +# # By following these steps, PINA simplifies applying deep learning to scientific computing and differential problems. -# -# +# +# # ## A Simple Regression Problem in PINA # We'll start with a simple regression problem [2] of approximating the following function with a Neural Net model $\mathcal{M}_{\theta}$: -# $$y = x^3 + \epsilon, \quad \epsilon \sim \mathcal{N}(0, 9)$$ -# using only 20 samples: -# +# $$y = x^3 + \epsilon, \quad \epsilon \sim \mathcal{N}(0, 9)$$ +# using only 20 samples: +# # $$x_i \sim \mathcal{U}[-3, 3], \; \forall i \in \{1, \dots, 20\}$$ -# +# # Using PINA, we will: -# +# # - Generate a synthetic dataset. # - Implement a **Bayesian regressor**. # - Use **Monte Carlo (MC) Dropout** for **Bayesian inference** and **uncertainty estimation**. -# +# # This example highlights how PINA can be used for classic regression tasks with probabilistic modeling capabilities. Let's first import useful modules! # In[ ]: @@ -99,21 +99,21 @@ from pina import Condition, LabelTensor from pina.problem import AbstractProblem -from pina.geometry import CartesianDomain +from pina.domain import CartesianDomain # #### ***Problem & Data*** -# +# # We'll start by defining a `BayesianProblem` inheriting from `AbstractProblem` to handle input/output data. This is suitable when data is available. For other cases like PDEs without data, use: -# +# # - `SpatialProblem` – for spatial variables # - `TimeDependentProblem` – for temporal variables # - `ParametricProblem` – for parametric inputs # - `InverseProblem` – for parameter estimation from observations -# +# # but we will see this more in depth in a while! -# In[21]: +# In[ ]: # (a) Data generation and plot @@ -127,7 +127,7 @@ class BayesianProblem(AbstractProblem): output_variables = ["y"] input_variables = ["x"] - conditions = {"data": Condition(input_points=x, output_points=y)} + conditions = {"data": Condition(input=x, target=y)} problem = BayesianProblem() @@ -140,17 +140,17 @@ class BayesianProblem(AbstractProblem): # We highlight two very important features of PINA -# -# 1. **`LabelTensor` Structure** -# - Alongside the standard `torch.Tensor`, PINA introduces the `LabelTensor` structure, which allows **string-based indexing**. -# - Ideal for managing and stacking tensors with different labels (e.g., `"x"`, `"t"`, `"u"`) for improved clarity and organization. +# +# 1. **`LabelTensor` Structure** +# - Alongside the standard `torch.Tensor`, PINA introduces the `LabelTensor` structure, which allows **string-based indexing**. +# - Ideal for managing and stacking tensors with different labels (e.g., `"x"`, `"t"`, `"u"`) for improved clarity and organization. # - You can still use standard PyTorch tensors if needed. -# -# 2. **`Condition` Object** -# - The `Condition` object enforces the **constraints** that the model $\mathcal{M}_{\theta}$ must satisfy, such as boundary or initial conditions. +# +# 2. **`Condition` Object** +# - The `Condition` object enforces the **constraints** that the model $\mathcal{M}_{\theta}$ must satisfy, such as boundary or initial conditions. # - It ensures that the model adheres to the specific requirements of the problem, making constraint handling more intuitive and streamlined. -# In[63]: +# In[ ]: # EXTRA - on the use of LabelTensor @@ -168,14 +168,14 @@ class BayesianProblem(AbstractProblem): # #### ***Model Design*** -# -# We will now solve the problem using a **simple PyTorch Neural Network** with **Dropout**, which we will implement from scratch following [2]. +# +# We will now solve the problem using a **simple PyTorch Neural Network** with **Dropout**, which we will implement from scratch following [2]. # It's important to note that PINA provides a wide range of **state-of-the-art (SOTA)** architectures in the `pina.model` module, which you can explore further [here](https://mathlab.github.io/PINA/_rst/_code.html#models). -# +# # #### ***Solver Selection*** -# -# For this task, we will use a straightforward **supervised learning** approach by importing the `SupervisedSolver` from `pina.solvers`. The solver is responsible for defining the training strategy. -# +# +# For this task, we will use a straightforward **supervised learning** approach by importing the `SupervisedSolver` from `pina.solvers`. The solver is responsible for defining the training strategy. +# # The `SupervisedSolver` is designed to handle typical regression tasks effectively by minimizing the following loss function: # $$ # \mathcal{L}_{\rm{problem}} = \frac{1}{N}\sum_{i=1}^N @@ -185,17 +185,17 @@ class BayesianProblem(AbstractProblem): # $$ # \mathcal{L}(v) = \| v \|^2_2. # $$ -# +# # #### **Training** -# +# # Next, we will use the `Trainer` class to train the model. The `Trainer` class, based on **PyTorch Lightning**, offers many features that help: # - **Improve model accuracy** # - **Reduce training time and memory usage** -# - **Facilitate logging and visualization** -# +# - **Facilitate logging and visualization** +# # The great work done by the PyTorch Lightning team ensures a streamlined training process. -# In[64]: +# In[ ]: from pina.solver import SupervisedSolver @@ -231,18 +231,18 @@ def forward(self, x): # #### ***Model Training Complete! Now Visualize the Solutions*** -# +# # The model has been trained! Since we used **Dropout** during training, the model is probabilistic (Bayesian) [3]. This means that each time we evaluate the forward pass on the input points $x_i$, the results will differ due to the stochastic nature of Dropout. -# +# # To visualize the model's predictions and uncertainty, we will: -# +# # 1. **Evaluate the Forward Pass**: Perform multiple forward passes to get different predictions for each input $x_i$. # 2. **Compute the Mean**: Calculate the average prediction $\mu_\theta$ across all forward passes. # 3. **Compute the Standard Deviation**: Calculate the variability of the predictions $\sigma_\theta$, which indicates the model's uncertainty. -# +# # This allows us to understand not only the predicted values but also the confidence in those predictions. -# In[65]: +# In[ ]: x_test = LabelTensor(torch.linspace(-4, 4, 100).reshape(-1, 1), "x") @@ -267,34 +267,34 @@ def forward(self, x): # ## PINA for Physics-Informed Machine Learning -# +# # In the previous section, we used PINA for **supervised learning**. However, one of its main strengths lies in **Physics-Informed Machine Learning (PIML)**, specifically through **Physics-Informed Neural Networks (PINNs)**. -# +# # ### What Are PINNs? -# +# # PINNs are deep learning models that integrate the laws of physics directly into the training process. By incorporating **differential equations** and **boundary conditions** into the loss function, PINNs allow the modeling of complex physical systems while ensuring the predictions remain consistent with scientific laws. -# +# # ### Solving a 2D Poisson Problem -# +# # In this section, we will solve a **2D Poisson problem** with **Dirichlet boundary conditions** on an **hourglass-shaped domain** using a simple PINN [4]. You can explore other PINN variants, e.g. [5] or [6] in PINA by visiting the [PINA solvers documentation](https://mathlab.github.io/PINA/_rst/_code.html#solvers). We aim to solve the following 2D Poisson problem: -# +# # $$ # \begin{cases} # \Delta u(x, y) = \sin{(\pi x)} \sin{(\pi y)} & \text{in } D, \\ -# u(x, y) = 0 & \text{on } \partial D +# u(x, y) = 0 & \text{on } \partial D # \end{cases} # $$ -# +# # where $D$ is an **hourglass-shaped domain** defined as the difference between a **Cartesian domain** and two intersecting **ellipsoids**, and $\partial D$ is the boundary of the domain. -# +# # ### Building Complex Domains -# +# # PINA allows you to build complex geometries easily. It provides many built-in domain shapes and Boolean operators for combining them. For this problem, we will define the hourglass-shaped domain using the existing `CartesianDomain` and `EllipsoidDomain` classes, with Boolean operators like `Difference` and `Union`. -# +# # > **👉 If you are interested in exploring the `domain` module in more detail, check out [this tutorial](https://mathlab.github.io/PINA/_rst/tutorials/tutorial6/tutorial.html).** -# +# -# In[66]: +# In[ ]: from pina.domain import EllipsoidDomain, Difference, CartesianDomain, Union @@ -333,10 +333,10 @@ def forward(self, x): # #### Plotting the domain -# +# # Nice! Now that we have built the domain, let's try to plot it -# In[67]: +# In[ ]: plt.figure(figsize=(8, 4)) @@ -360,14 +360,14 @@ def forward(self, x): # #### Writing the Poisson Problem Class -# -# Very good! Now we will implement the problem class for the 2D Poisson problem. Unlike the previous examples, where we inherited from `AbstractProblem`, for this problem, we will inherit from the `SpatialProblem` class. -# +# +# Very good! Now we will implement the problem class for the 2D Poisson problem. Unlike the previous examples, where we inherited from `AbstractProblem`, for this problem, we will inherit from the `SpatialProblem` class. +# # The reason for this is that the Poisson problem involves **spatial variables** as input, so we use `SpatialProblem` to handle such cases. -# +# # This will allow us to define the problem with spatial dependencies and set up the neural network model accordingly. -# In[69]: +# In[ ]: from pina.problem import SpatialProblem @@ -402,15 +402,15 @@ class Poisson(SpatialProblem): # As you can see, writing the problem class for a differential equation in PINA is straightforward! The main differences are: -# +# # - We inherit from **`SpatialProblem`** instead of `AbstractProblem` to account for spatial variables. # - We use **`domain`** and **`equation`** inside the `Condition` to define the problem. -# +# # The `Equation` class can be very useful for creating modular problem classes. If you're interested, check out [this tutorial](https://mathlab.github.io/PINA/_rst/tutorial12/tutorial.html) for more details. There's also a dedicated [tutorial](https://mathlab.github.io/PINA/_rst/tutorial16/tutorial.html) for building custom problems! -# +# # Once the problem class is set, we need to **sample the domain** to obtain the data. PINA will automatically handle this, and if you forget to sample, an error will be raised before training begins 😉. -# In[70]: +# In[ ]: print("Points are not automatically sampled, you can see this by:") @@ -422,16 +422,16 @@ class Poisson(SpatialProblem): # ### Building the Model -# +# # After setting the problem and sampling the domain, the next step is to **build the model** $\mathcal{M}_{\theta}$. -# +# # For this, we will use the custom PINA models available [here](https://mathlab.github.io/PINA/_rst/_code.html#models). Specifically, we will use a **feed-forward neural network** by importing the `FeedForward` class. -# -# This neural network takes the **coordinates** (in this case `['x', 'y']`) as input and outputs the unknown field of the Poisson problem. -# +# +# This neural network takes the **coordinates** (in this case `['x', 'y']`) as input and outputs the unknown field of the Poisson problem. +# # In this tutorial, the neural network is composed of 2 hidden layers, each with 120 neurons and tanh activation. -# In[72]: +# In[ ]: from pina.model import FeedForward @@ -445,33 +445,33 @@ class Poisson(SpatialProblem): # ### Solver Selection -# +# # The thir part of the PINA pipeline involves using a **Solver**. -# +# # In this tutorial, we will use the **classical PINN** solver. However, many other variants are also available and we invite to try them! -# +# # #### Loss Function in PINA -# +# # The loss function in the **classical PINN** is defined as follows: -# +# # $$\theta_{\rm{best}}=\min_{\theta}\mathcal{L}_{\rm{problem}}(\theta), \quad \mathcal{L}_{\rm{problem}}(\theta)= \frac{1}{N_{D}}\sum_{i=1}^N # \mathcal{L}(\Delta\mathcal{M}_{\theta}(\mathbf{x}_i, \mathbf{y}_i) - \sin(\pi x_i)\sin(\pi y_i)) + # \frac{1}{N}\sum_{i=1}^N # \mathcal{L}(\mathcal{M}_{\theta}(\mathbf{x}_i, \mathbf{y}_i))$$ -# +# # This loss consists of: # 1. The **differential equation residual**: Ensures the model satisfies the Poisson equation. # 2. The **boundary condition**: Ensures the model satisfies the Dirichlet boundary condition. -# +# # ### Training -# +# # For the last part of the pipeline we need a `Trainer`. We will train the model for **1000 epochs** using the default optimizer parameters. These parameters can be adjusted as needed. For more details, check the solvers documentation [here](https://mathlab.github.io/PINA/_rst/_code.html#solvers). -# +# # To track metrics during training, we use the **`MetricTracker`** class. -# +# # > **👉 Want to know more about `Trainer` and how to boost PINA performance, check out [this tutorial](https://mathlab.github.io/PINA/_rst/tutorials/tutorial11/tutorial.html).** -# In[73]: +# In[ ]: from pina.solver import PINN @@ -495,7 +495,7 @@ class Poisson(SpatialProblem): # Done! We can plot the solution and its residual -# In[75]: +# In[ ]: # sample points in the domain. remember to set requires_grad! @@ -527,28 +527,28 @@ class Poisson(SpatialProblem): # ## What's Next? -# +# # Congratulations on completing the introductory tutorial of **PINA**! Now that you have a solid foundation, here are a few directions you can explore: -# +# # 1. **Explore Advanced Solvers**: Dive into more advanced solvers like **SAPINN** or **RBAPINN** and experiment with different variations of Physics-Informed Neural Networks. # 2. **Apply PINA to New Problems**: Try solving other types of differential equations or explore inverse problems and parametric problems using the PINA framework. # 3. **Optimize Model Performance**: Use the `Trainer` class to enhance model performance by exploring features like dynamic learning rates, early stopping, and model checkpoints. -# +# # 4. **...and many more!** — There are countless directions to further explore, from testing on different problems to refining the model architecture! -# +# # For more resources and tutorials, check out the [PINA Documentation](https://mathlab.github.io/PINA/). -# -# +# +# # ### References -# +# # [1] *Coscia, Dario, et al. "Physics-informed neural networks for advanced modeling." Journal of Open Source Software, 2023.* -# +# # [2] *Hernández-Lobato, José Miguel, and Ryan Adams. "Probabilistic backpropagation for scalable learning of bayesian neural networks." International conference on machine learning, 2015.* -# +# # [3] *Gal, Yarin, and Zoubin Ghahramani. "Dropout as a bayesian approximation: Representing model uncertainty in deep learning." International conference on machine learning, 2016.* -# +# # [4] *Raissi, Maziar, Paris Perdikaris, and George E. Karniadakis. "Physics-informed neural networks: A deep learning framework for solving forward and inverse problems involving nonlinear partial differential equations." Journal of Computational Physics, 2019.* -# +# # [5] *McClenny, Levi D., and Ulisses M. Braga-Neto. "Self-adaptive physics-informed neural networks." Journal of Computational Physics, 2023.* -# +# # [6] *Anagnostopoulos, Sokratis J., et al. "Residual-based attention in physics-informed neural networks." Computer Methods in Applied Mechanics and Engineering, 2024.* diff --git a/tutorials/tutorial22/tutorial.py b/tutorials/tutorial22/tutorial.py index 6c2fae659..48eefdaa7 100644 --- a/tutorials/tutorial22/tutorial.py +++ b/tutorials/tutorial22/tutorial.py @@ -2,15 +2,15 @@ # coding: utf-8 # # Tutorial: Reduced Order Model with Graph Neural Networks -# +# # [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/mathLab/PINA/blob/master/tutorials/tutorial22/tutorial.ipynb) -# -# +# +# # > ##### ⚠️ ***Before starting:*** # > We assume you are already familiar with the concepts covered in the [Data Structure for SciML](https://mathlab.github.io/PINA/tutorial19/tutorial.html) tutorial. If not, we strongly recommend reviewing them before exploring this advanced topic. -# +# # In this tutorial, we will demonstrate a typical use case of **PINA** for Reduced Order Modelling using Graph Convolutional Neural Network. The tutorial is largely inspired by the paper [A graph convolutional autoencoder approach to model order reduction for parametrized PDEs](https://www.sciencedirect.com/science/article/pii/S0021999124000111). -# +# # Let's start by importing the useful modules: # In[ ]: @@ -25,7 +25,9 @@ IN_COLAB = False if IN_COLAB: get_ipython().system('pip install "pina-mathlab[tutorial]"') - get_ipython().system('wget "https://github.com/mathLab/PINA/raw/refs/heads/master/tutorials/tutorial22/holed_poisson.pt" -O "holed_poisson.pt"') + get_ipython().system( + 'wget "https://github.com/mathLab/PINA/raw/refs/heads/master/tutorials/tutorial22/holed_poisson.pt" -O "holed_poisson.pt"' + ) import torch from torch import nn @@ -49,22 +51,22 @@ # ## Data Generation -# +# # In this tutorial, we will focus on solving the parametric **Poisson** equation, a linear PDE. The equation is given by: -# +# # $$ # \begin{cases} # -\frac{1}{10}\Delta u = 1, &\Omega(\boldsymbol{\mu}),\\ # u = 0, &\partial \Omega(\boldsymbol{\mu}). # \end{cases} # $$ -# -# In this equation, $\Omega(\boldsymbol{\mu}) = [0, 1]\times[0,1] \setminus [\mu_1, \mu_2]\times[\mu_1+0.3, \mu_2+0.3]$ represents the spatial domain characterized by a parametrized hole defined via $\boldsymbol{\mu} = (\mu_1, \mu_2) \in \mathbb{P} = [0.1, 0.6]\times[0.1, 0.6]$. Thus, the geometrical parameters define the left bottom corner of a square obstacle of dimension $0.3$. The problem is coupled with homogenous Dirichlet conditions on both internal and external boundaries. In this setting, $u(\mathbf{x}, \boldsymbol{\mu})\in \mathbb{R}$ is the value of the function $u$ at each point in space for a specific parameter $\boldsymbol{\mu}$. -# -# We have already generated data for different parameters. The dataset is obtained via $\mathbb{P}^1$ FE method, and an equispaced sampling with 11 points in each direction of the parametric space. -# +# +# In this equation, $\Omega(\boldsymbol{\mu}) = [0, 1]\times[0,1] \setminus [\mu_1, \mu_2]\times[\mu_1+0.3, \mu_2+0.3]$ represents the spatial domain characterized by a parametrized hole defined via $\boldsymbol{\mu} = (\mu_1, \mu_2) \in \mathbb{P} = [0.1, 0.6]\times[0.1, 0.6]$. Thus, the geometrical parameters define the left bottom corner of a square obstacle of dimension $0.3$. The problem is coupled with homogenous Dirichlet conditions on both internal and external boundaries. In this setting, $u(\mathbf{x}, \boldsymbol{\mu})\in \mathbb{R}$ is the value of the function $u$ at each point in space for a specific parameter $\boldsymbol{\mu}$. +# +# We have already generated data for different parameters. The dataset is obtained via $\mathbb{P}^1$ FE method, and an equispaced sampling with 11 points in each direction of the parametric space. +# # The goal is to build a Reduced Order Model that given a new parameter $\boldsymbol{\mu}^*$, is able to get the solution $u$ *for any discretization* $\mathbf{x}$. To this end, we will train a Graph Convolutional Autoencoder Reduced Order Model (GCA-ROM), as presented in [A graph convolutional autoencoder approach to model order reduction for parametrized PDEs](https://www.sciencedirect.com/science/article/pii/S0021999124000111). We will cover the architecture details later, but for now, let’s start by importing the data. -# +# # **Note:** # The numerical integration is obtained using a finite element method with the [RBniCS library](https://www.rbnicsproject.org/). @@ -93,42 +95,42 @@ # ## Graph-Based Reduced Order Modeling -# +# # In this problem, the geometry of the spatial domain is **unstructured**, meaning that classical grid-based methods (e.g., CNNs) are not well suited. Instead, we represent the mesh as a **graph**, where nodes correspond to spatial degrees of freedom and edges represent connectivity. This makes **Graph Neural Networks (GNNs)**, and in particular **Graph Convolutional Networks (GCNs)**, a natural choice to process the data. -# +# #

# GCA-ROM #

-# +# # To reduce computational complexity while preserving accuracy, we employ a **Reduced Order Modeling (ROM)** strategy (see picture above). The idea is to map high-dimensional simulation data $u(\mathbf{x}, \boldsymbol{\mu})$ to a compact **latent space** using a **graph convolutional encoder**, and then reconstruct it back via a **decoder** (offline phase). The latent representation captures the essential features of the solution manifold. Moreover, we can learn a **parametric map** $\mathcal{M}$ from the parameter space $\boldsymbol{\mu}$ directly into the latent space, enabling predictions for new unseen parameters. -# +# # Formally, the autoencoder consists of an **encoder** $\mathcal{E}$, a **decoder** $\mathcal{D}$, and a **parametric mapping** $\mathcal{M}$: # $$ -# z = \mathcal{E}(u(\mathbf{x}, \boldsymbol{\mu})), +# z = \mathcal{E}(u(\mathbf{x}, \boldsymbol{\mu})), # \quad # \hat{u}(\mathbf{x}, \boldsymbol{\mu}) = \mathcal{D}(z), # \quad # \hat{z} = \mathcal{M}(\boldsymbol{\mu}), # $$ # where $z \in \mathbb{R}^r$ is the latent representation with $r \ll N$ (the number of degrees of freedom) and the **hat notation** ($\hat{u}, \hat{z}$) indicates *learned or approximated quantities*. -# +# # The training objective balances two terms: # 1. **Reconstruction loss**: ensuring the autoencoder can faithfully reconstruct $u$ from $z$. # 2. **Latent consistency loss**: enforcing that the parametric map $\mathcal{M}(\boldsymbol{\mu})$ approximates the encoder’s latent space. -# +# # The combined loss function is: # $$ -# \mathcal{L}(\theta) = \frac{1}{N} \sum_{i=1}^N -# \big\| u(\mathbf{x}, \boldsymbol{\mu}_i) - -# \mathcal{D}\!\big(\mathcal{E}(u(\mathbf{x}, \boldsymbol{\mu}_i))\big) +# \mathcal{L}(\theta) = \frac{1}{N} \sum_{i=1}^N +# \big\| u(\mathbf{x}, \boldsymbol{\mu}_i) - +# \mathcal{D}\!\big(\mathcal{E}(u(\mathbf{x}, \boldsymbol{\mu}_i))\big) # \big\|_2^2 # \;+\; \frac{1}{N} \sum_{i=1}^N # \big\| \mathcal{E}(u(\mathbf{x}, \boldsymbol{\mu}_i)) - \mathcal{M}(\boldsymbol{\mu}_i) \big\|_2^2. # $$ # This framework leverages the expressive power of GNNs for unstructured geometries and the efficiency of ROMs for handling parametric PDEs. -# +# # We will now build the autoencoder network, which is a `nn.Module` with two methods: `encode` and `decode`. -# +# # In[3]: @@ -196,17 +198,17 @@ def decode(self, z, decoding_graph=None): # Great! We now need to build the graph structure (a PyTorch Geometric `Data` object) from the numerical solver outputs. -# +# # The solver provides the solution values $u(\mathbf{x}, \boldsymbol{\mu})$ for each parameter instance $\boldsymbol{\mu}$, along with the node coordinates $(x, y)$ of the unstructured mesh. Because the geometry is not defined on a regular grid, we naturally represent the mesh as a graph: -# -# - **Nodes** correspond to spatial points in the mesh. Each node stores the **solution value** $u$ at that point as a feature. +# +# - **Nodes** correspond to spatial points in the mesh. Each node stores the **solution value** $u$ at that point as a feature. # - **Edges** represent mesh connectivity. For each edge, we compute: -# - **Edge attributes**: the relative displacement vector between the two nodes. -# - **Edge weights**: the Euclidean distance between the connected nodes. +# - **Edge attributes**: the relative displacement vector between the two nodes. +# - **Edge weights**: the Euclidean distance between the connected nodes. # - **Positions** store the physical $(x, y)$ coordinates of the nodes. -# +# # For each parameter realization $\boldsymbol{\mu}_i$, we therefore construct a PyTorch Geometric `Data` object: -# +# # In[4]: @@ -237,11 +239,11 @@ def decode(self, z, decoding_graph=None): # ## Training with PINA -# -# Everything is now ready! We can use **PINA** to train the model, following the workflow from previous tutorials. First, we need to define the problem. In this case, we will use the [`SupervisedProblem`](https://mathlab.github.io/PINA/_rst/problem/zoo/supervised_problem.html#module-pina.problem.zoo.supervised_problem), which expects: -# -# - **Input**: the parameter tensor $\boldsymbol{\mu}$ describing each scenario. -# - **Output**: the corresponding graph structure (PyTorch Geometric `Data` object) that we aim to reconstruct. +# +# Everything is now ready! We can use **PINA** to train the model, following the workflow from previous tutorials. First, we need to define the problem. In this case, we will use the [`SupervisedProblem`](https://mathlab.github.io/PINA/_rst/problem/zoo/supervised_problem.html#module-pina.problem.zoo.supervised_problem), which expects: +# +# - **Input**: the parameter tensor $\boldsymbol{\mu}$ describing each scenario. +# - **Output**: the corresponding graph structure (PyTorch Geometric `Data` object) that we aim to reconstruct. # In[5]: @@ -249,9 +251,9 @@ def decode(self, z, decoding_graph=None): problem = SupervisedProblem(params, graphs) -# Next, we build the **autoencoder network** and the **interpolation network**. -# -# - The **Graph Convolutional Autoencoder (GCA)** encodes the high-dimensional graph data into a compact latent space and reconstructs the graphs from this latent representation. +# Next, we build the **autoencoder network** and the **interpolation network**. +# +# - The **Graph Convolutional Autoencoder (GCA)** encodes the high-dimensional graph data into a compact latent space and reconstructs the graphs from this latent representation. # - The **interpolation network** (or parametric map) learns to map a new parameter $\boldsymbol{\mu}^*$ directly into the latent space, enabling the model to predict solutions for unseen parameter instances without running the full encoder. # In[6]: @@ -269,11 +271,11 @@ def decode(self, z, decoding_graph=None): ) -# Finally, we will use the [`ReducedOrderModelSolver`](https://mathlab.github.io/PINA/_rst/solver/supervised_solver/reduced_order_model.html#pina.solver.supervised_solver.reduced_order_model.ReducedOrderModelSolver) to perform the training, as discussed earlier. -# -# This solver requires two components: -# - an **interpolation network**, which maps parameters $\boldsymbol{\mu}$ to the latent space, and -# - a **reduction network**, which in our case is the **autoencoder** that compresses and reconstructs the graph data. +# Finally, we will use the [`ReducedOrderModelSolver`](https://mathlab.github.io/PINA/_rst/solver/supervised_solver/reduced_order_model.html#pina.solver.supervised_solver.reduced_order_model.ReducedOrderModelSolver) to perform the training, as discussed earlier. +# +# This solver requires two components: +# - an **interpolation network**, which maps parameters $\boldsymbol{\mu}$ to the latent space, and +# - a **reduction network**, which in our case is the **autoencoder** that compresses and reconstructs the graph data. # In[7]: @@ -319,8 +321,8 @@ def forward(self, output, target): # Once the model is trained, we can test the reconstruction by following two steps: -# -# 1. **Interpolate**: Use the `interpolation_network` to map a new parameter $\boldsymbol{\mu}^*$ to the latent space. +# +# 1. **Interpolate**: Use the `interpolation_network` to map a new parameter $\boldsymbol{\mu}^*$ to the latent space. # 2. **Decode**: Pass the interpolated latent vector through the autoencoder (`reduction_network`) to reconstruct the corresponding graph data. # In[9]: @@ -392,18 +394,18 @@ def forward(self, output, target): plt.show() -# Nice! We can see that the network is correctly learning the solution operator, and the workflow was very straightforward. -# +# Nice! We can see that the network is correctly learning the solution operator, and the workflow was very straightforward. +# # You may notice that the network outputs are not as smooth as the actual solution. Don’t worry — training for longer (e.g., ~5000 epochs) will produce a smoother, more accurate reconstruction. -# +# # ## What's Next? -# +# # Congratulations on completing the introductory tutorial on **Graph Convolutional Reduced Order Modeling**! Now that you have a solid foundation, here are a few directions to explore: -# +# # 1. **Experiment with Training Duration** — Try different training durations and adjust the network architecture to optimize performance. Explore different integral kernels and observe how the results vary. -# +# # 2. **Explore Physical Constraints** — Incorporate physics-informed terms or constraints during training to improve model generalization and ensure physically consistent predictions. -# +# # 3. **...and many more!** — The possibilities are vast! Continue experimenting with advanced configurations, solvers, and features in PINA. -# +# # For more resources and tutorials, check out the [PINA Documentation](https://mathlab.github.io/PINA/). diff --git a/tutorials/tutorial23/tutorial.ipynb b/tutorials/tutorial23/tutorial.ipynb new file mode 100644 index 000000000..1a3355b10 --- /dev/null +++ b/tutorials/tutorial23/tutorial.ipynb @@ -0,0 +1,470 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0c602c59", + "metadata": {}, + "source": [ + "# Tutorial: Data-driven System Identification with SINDy\n", + "\n", + "[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/mathLab/PINA/blob/master/tutorials/tutorial23/tutorial.ipynb)\n", + "\n", + "\n", + "> ##### ⚠️ ***Before starting:***\n", + "> We assume you are already familiar with the concepts covered in the [Getting started with PINA](https://mathlab.github.io/PINA/_tutorial.html#getting-started-with-pina) tutorial. If not, we strongly recommend reviewing them before exploring this advanced topic.\n", + "\n", + "In this tutorial, we will demonstrate a typical use case of **PINA** for Data-driven system identification using SINDy. The tutorial is largely inspired by the paper [Discovering governing equations from data by sparse identification of nonlinear dynamical systems](dx.doi.org/10.1073/pnas.1517384113).\n", + "\n", + "Let's start by importing the useful modules:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3f1f226d", + "metadata": {}, + "outputs": [], + "source": [ + "## routine needed to run the notebook on Google Colab\n", + "try:\n", + " import google.colab\n", + "\n", + " IN_COLAB = True\n", + "except:\n", + " IN_COLAB = False\n", + "if IN_COLAB:\n", + " !pip install \"pina-mathlab[tutorial]\"\n", + "\n", + "import torch\n", + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "import warnings\n", + "\n", + "np.random.seed(0)\n", + "warnings.filterwarnings(\"ignore\")\n", + "\n", + "from scipy.integrate import odeint\n", + "from pina import Trainer, LabelTensor\n", + "from pina.problem.zoo import SupervisedProblem\n", + "from pina.solver import SupervisedSolver\n", + "from pina.optim import TorchOptimizer\n", + "from pina.model import SINDy" + ] + }, + { + "cell_type": "markdown", + "id": "1632a783", + "metadata": {}, + "source": [ + "## Data generation\n", + "In this tutorial, we'll focus on the **identification** of a dynamical system starting only from a finite set of **snapshots**.\n", + "More precisely, we'll assume that the dynamics is governed by dynamical system written as follows:\n", + "$$\\dot{\\boldsymbol{x}}(t)=\\boldsymbol{f}(\\boldsymbol{x}(t)),$$\n", + "along with suitable initial conditions.\n", + "For simplicity, we'll omit the argument of $\\boldsymbol{x}$ from this point onward.\n", + "\n", + "Since $\\boldsymbol{f}$ is unknown, we want to model it.\n", + "While neural networks could be used to find an expression for $\\boldsymbol{f}$, in certain contexts - for instance, to perform long-horizon forecasting - it might be useful to have an **explicit** set of equations describing it, which would also allow for a better degree of **interpretability** of our model.\n", + "\n", + "As a result, we use SINDy (introduced in [this paper](https://www.pnas.org/doi/full/10.1073/pnas.1517384113)), which we'll describe later on.\n", + "Now, instead, we describe the system that is going to be considered in this tutorial: the **Lorenz** system.\n", + "\n", + "The Lorenz system is a set of three ordinary differential equations and is a simplified model of atmospheric convection.\n", + "It is well-known because it can exhibit chaotic behavior, _i.e._, for given values of the parameters solutions are highly sensitive to small perturbations in the initial conditions, making forecasting extremely challenging.\n", + "\n", + "Mathematically speaking, we can write the Lorenz equations as\n", + "$$\n", + "\\begin{cases}\n", + "\\dot{x}=\\sigma(y-x)\\\\\n", + "\\dot{y}=x(\\rho-z) - y\\\\\n", + "\\dot{z}=xy-\\beta z.\n", + "\\end{cases}\n", + "$$\n", + "With $\\sigma = 10,\\, \\rho = 28$, and $\\beta=8/3$, the solutions trace out the famous butterfly-shaped Lorenz attractor.\n", + "\n", + "With the following lines of code, we just generate the dataset for SINDy and plot some trajectories.\n", + "\n", + "**Disclaimer**: of course, here we use the equations defining the Lorenz system just to generate the data.\n", + "If we had access to the dynamical term $\\boldsymbol{f}$, there would be no need to use SINDy." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3e7c600b", + "metadata": {}, + "outputs": [], + "source": [ + "sigma, rho, beta = 10.0, 28.0, 8 / 3\n", + "\n", + "\n", + "def lorenz(x, t):\n", + " dx = np.zeros(3)\n", + " dx[0] = sigma * (x[1] - x[0])\n", + " dx[1] = x[0] * (rho - x[2]) - x[1]\n", + " dx[2] = x[0] * x[1] - beta * x[2]\n", + " return dx\n", + "\n", + "\n", + "n_ic_s = 200 # number of initial conditions\n", + "T = 1000 # number of timesteps\n", + "dt = 0.001 # timestep\n", + "t = np.linspace(0, (T - 1) * dt, T)\n", + "dim = 3\n", + "\n", + "x0s = (np.random.rand(n_ic_s, dim) - 0.5) * 30.0 # Random initial conditions\n", + "\n", + "X = np.zeros((n_ic_s, T, dim))\n", + "for i in range(n_ic_s):\n", + " X[i] = odeint(lorenz, x0s[i], t) # integrated trajectories\n", + "\n", + "\n", + "def plot_n_conditions(X, n_to_plot):\n", + " fig = plt.figure(figsize=(6, 5))\n", + " ax = fig.add_subplot(111, projection=\"3d\")\n", + "\n", + " for i in range(n_to_plot):\n", + " ax.plot(X[i, :, 0], X[i, :, 1], X[i, :, 2], lw=1)\n", + "\n", + " ax.set_xlabel(\"$x$\")\n", + " ax.set_ylabel(\"$y$\")\n", + " ax.set_zlabel(\"$z$\")\n", + "\n", + " plt.tight_layout()\n", + " plt.show()\n", + "\n", + "\n", + "plot_n_conditions(X, n_ic_s)" + ] + }, + { + "cell_type": "markdown", + "id": "a892f938", + "metadata": {}, + "source": [ + "## Sparse Identification of Nonlinear Dynamics\n", + "The core idea of SINDy is to model $\\boldsymbol f$ as a linear combination of functions in a library $\\Theta$ of **candidate** functions.\n", + "In other words, assume that we have $r$ functions which might be suitable to describe the system's dynamics (_e.g._, $x,\\, y,\\, x^2,\\, xz,\\, \\dots,\\,\\sin(x)$, $\\dots$).\n", + "For each component of $\\boldsymbol{f}$ at a given point $\\boldsymbol{x}$, we want to write\n", + "$$\n", + "\\dot{x}_i = f_i(\\boldsymbol{x}) = \\sum_{k}\\Theta(\\boldsymbol{x})_{k}\\xi_{k,i},\n", + "$$\n", + "with $\\boldsymbol{\\xi}_i\\in\\mathbb{R}^r$ a vector of **coefficients** telling us which terms are active in the expression of $f_i$.\n", + "\n", + "Since we are in a supervised setting, we assume that we have at our disposal the snapshot matrix $\\boldsymbol{X}$ and a matrix $\\dot{\\boldsymbol{X}}$ containing time **derivatives** at the corresponding time instances.\n", + "Then, we can just impose that the previous relation holds on the data at our disposal.\n", + "That is, our optimization problem will read as follows:\n", + "$$\n", + "\\min_{\\boldsymbol{\\Xi}}\\|\\dot{\\boldsymbol{X}}-\\Theta(\\boldsymbol{X})\\boldsymbol{\\Xi}\\|_2^2.\n", + "$$\n", + "\n", + "Notice, however, that the solution to the previous equation might not be **sparse**, as there might be many non-zero terms in it.\n", + "In practice, many physical systems are described by a parsimonious and **interpretable** set of equations.\n", + "Thus, we also impose a $L^1$ **penalization** on the model weights, encouraging them to be small in magnitude and trying to enforce sparsity.\n", + "The final loss is then expressed as\n", + "\n", + "$$\n", + "\\min_{\\boldsymbol{\\Xi}}\\bigl(\\|\\dot{\\boldsymbol{X}}-\\Theta(\\boldsymbol{X})\\boldsymbol{\\Xi}\\|_2^2 + \\lambda\\|\\boldsymbol{\\Xi}\\|_1\\bigr),\n", + "$$\n", + "with $\\lambda\\in\\mathbb{R}^+$ a hyperparameter.\n", + "\n", + "Let us begin by computing the time derivatives of the data.\n", + "Of course, usually we do not have access to the exact time derivatives of the system, meaning that $\\dot{\\boldsymbol{X}}$ needs to be **approximated**.\n", + "Here we do it using a simple Finite Difference (FD) scheme, but [more sophisticated ideas](https://arxiv.org/abs/2505.16058) could be considered." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0480bd46", + "metadata": {}, + "outputs": [], + "source": [ + "dXdt = np.gradient(X, t, axis=1, edge_order=2)\n", + "X_torch = torch.tensor(X, dtype=torch.float32).reshape(\n", + " (-1, dim)\n", + ") # X_torch has shape (B, dim)\n", + "dXdt_torch = torch.tensor(dXdt, dtype=torch.float32).reshape((-1, dim))" + ] + }, + { + "cell_type": "markdown", + "id": "3f0c5cab", + "metadata": {}, + "source": [ + "We create two `LabelTensor` objects to keep everything as readable as possible." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "af16aa54", + "metadata": {}, + "outputs": [], + "source": [ + "X_torch = LabelTensor(X_torch, [\"x\", \"y\", \"z\"])\n", + "dXdt_torch = LabelTensor(dXdt_torch, [\"dxdt\", \"dydt\", \"dzdt\"])" + ] + }, + { + "cell_type": "markdown", + "id": "42ca14b1", + "metadata": {}, + "source": [ + "Now we define the **library of candidate functions**.\n", + "In our case, it will consist of polynomials of degree at most $2$ in the state variables.\n", + "While the `SINDy` class in **PINA** expects a **list** of callables, here we define also dictionary, as its keys will be used to print the retrieved equations, enhancing the model interpretability and allowing it to be compared to the original Lorenz system.\n", + "Notice how readable the code is as a result of the use of the `LabelTensor` class!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "805a5aee", + "metadata": {}, + "outputs": [], + "source": [ + "function_dict = {\n", + " \"1\": lambda u: torch.ones(u.shape[0], 1, device=u.device), # 1\n", + " \"x\": lambda u: u[\"x\"], # x\n", + " \"y\": lambda u: u[\"y\"], # y\n", + " \"z\": lambda u: u[\"z\"], # z\n", + " \"x^2\": lambda u: u[\"x\"].pow(2), # x^2\n", + " \"y^2\": lambda u: u[\"y\"].pow(2), # y^2\n", + " \"z^2\": lambda u: u[\"z\"].pow(2), # z^2\n", + " \"xy\": lambda u: u[\"x\"] * u[\"y\"], # xy\n", + " \"xz\": lambda u: u[\"x\"] * u[\"z\"], # xz\n", + " \"yz\": lambda u: u[\"y\"] * u[\"z\"], # yz\n", + "}\n", + "\n", + "function_library = [\n", + " _function for _function in function_dict.values()\n", + "] # input of the model constructor" + ] + }, + { + "cell_type": "markdown", + "id": "f122e52c", + "metadata": {}, + "source": [ + "## Training with PINA\n", + "We are now ready to train our model! We can use **PINA** to train the model, following the workflow from previous tutorials.\n", + "First, we need to define the problem. In this case, we will use the [`SupervisedProblem`](https://mathlab.github.io/PINA/_rst/problem/zoo/supervised_problem.html#module-pina.problem.zoo.supervised_problem), which expects: \n", + "\n", + "- **Input**: the state variables tensor $\\boldsymbol{X}$ containing all the collected snapshots. \n", + "- **Output**: the corresponding time derivatives $\\dot{\\boldsymbol{X}}$." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2e94b470", + "metadata": {}, + "outputs": [], + "source": [ + "_lambda = 1e-3\n", + "\n", + "model = SINDy(function_library, dim)\n", + "problem = SupervisedProblem(X_torch, dXdt_torch)" + ] + }, + { + "cell_type": "markdown", + "id": "849b4a33", + "metadata": {}, + "source": [ + "Finally, we will use the `SupervisedSolver` to perform the training as we're dealing with a supervised problem.\n", + "\n", + "Recall that we should use $L^1$-regularization on the model's weights to ensure sparsity. For the ease of implementation, we adopt $L^2$ regularization, which is less common in SINDy literature but will suffice in our case.\n", + "Additionally, more refined strategies could be used, for instance pruning coefficients below a certain **threshold** at every fixed number of epochs, but here we avoid further complications." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f19a48b3", + "metadata": {}, + "outputs": [], + "source": [ + "solver = SupervisedSolver(\n", + " problem,\n", + " model=model,\n", + " optimizer=TorchOptimizer(torch.optim.Adam, lr=1e-3, weight_decay=_lambda),\n", + " use_lt=False,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "41e1636e", + "metadata": {}, + "source": [ + "Training is performed as usual using the **`Trainer`** API." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "02931534", + "metadata": {}, + "outputs": [], + "source": [ + "trainer = Trainer(\n", + " solver,\n", + " accelerator=\"cpu\",\n", + " max_epochs=150,\n", + " train_size=0.8,\n", + " val_size=0.1,\n", + " test_size=0.1,\n", + " shuffle=True,\n", + " batch_size=512,\n", + " enable_model_summary=False,\n", + ")\n", + "\n", + "trainer.train()" + ] + }, + { + "cell_type": "markdown", + "id": "b725dc65", + "metadata": {}, + "source": [ + "Now we'll print the identified equations and compare them with the original ones.\n", + "\n", + "Before going on, we underline that after training there might be many coefficients that are small, yet still non-zero.\n", + "It is common for SINDy practitioners to interpret these coefficients as noise in the model and prune them.\n", + "This is typically done by fixing a threshold $\\tau\\in\\mathbb{R}^+$ and setting to $0$ all those $\\xi_{i,j}$ such that $|\\xi_{i,j}|<\\tau$.\n", + "\n", + "In the following cell, we also define a function to print the identified model." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "786ad778", + "metadata": {}, + "outputs": [], + "source": [ + "def print_coefficients(model, function_names, tau, vars=None):\n", + " with torch.no_grad():\n", + " Xi = model.coefficients.data.cpu().numpy()\n", + "\n", + " library_dim, dim = Xi.shape\n", + "\n", + " for j in range(dim):\n", + " terms = []\n", + " for i in range(library_dim):\n", + " coefficient = Xi[i, j]\n", + " if (\n", + " abs(coefficient) > tau\n", + " ): # do not print coefficients that are going to be pruned\n", + " function_name = function_names[i]\n", + " terms.append(f\"{coefficient:+.2f} * {function_name} \")\n", + "\n", + " equation = \" \".join(terms)\n", + "\n", + " if not equation:\n", + " equation = \"0\"\n", + " if vars is not None:\n", + " print(f\"d{vars[j]}/dt = {equation}\")\n", + " else:\n", + " print(f\"d(State_{j+1})/dt = {equation}\")\n", + "\n", + "\n", + "tau = 1e-1\n", + "\n", + "print_coefficients(model, list(function_dict.keys()), tau, vars=[\"x\", \"y\", \"z\"])\n", + "\n", + "with torch.no_grad(): # prune coefficients\n", + " mask = torch.abs(model.coefficients) >= tau\n", + " model.coefficients.data *= mask" + ] + }, + { + "cell_type": "markdown", + "id": "c6054546", + "metadata": {}, + "source": [ + "Good! While there are small errors on some of the coefficients, the active terms in the library have been correctly identified (recall that the original system reads as follows):\n", + "$$\n", + "\\begin{cases}\n", + "\\dot{x}=-10x+10y\\\\\n", + "\\dot{y}=28x - y-xz\\\\\n", + "\\dot{z}=-\\frac{8}{3} z+xy.\n", + "\\end{cases}\n", + "$$\n", + "\n", + "That's a good result, especially considering that we did not perform tuning on the weight decay hyperparameter $\\lambda$ and did not really care much about other optimization parameters.\n", + "\n", + "Let's plot a few trajectories!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b9b8f972", + "metadata": {}, + "outputs": [], + "source": [ + "def SINDy_equations(x, t): # we need a numpy array for odeint\n", + " with torch.no_grad():\n", + " x_torch = torch.tensor(x, dtype=torch.float32).unsqueeze(\n", + " 0\n", + " ) # shape (1, dim)\n", + " x_torch = LabelTensor(x_torch, [\"x\", \"y\", \"z\"])\n", + " dx = model(x_torch).squeeze(0)\n", + " return dx.numpy()\n", + "\n", + "\n", + "n_ic_s_test = 50\n", + "x0s = (np.random.rand(n_ic_s_test, dim) - 0.5) * 30.0\n", + "\n", + "X_sim = np.zeros((n_ic_s_test, T, dim))\n", + "for i in range(n_ic_s_test):\n", + " X_sim[i] = odeint(SINDy_equations, x0s[i], t)\n", + "\n", + "plot_n_conditions(X_sim, n_ic_s_test)" + ] + }, + { + "cell_type": "markdown", + "id": "de956cbe", + "metadata": {}, + "source": [ + "Great! We can see that the qualitative behavior of the system is really close to the real one.\n", + "\n", + "## What's next?\n", + "Congratulations on completing the introductory tutorial on **Data-driven System Identification with SINDy**! Now that you have a solid foundation, here are a few directions to explore:\n", + "\n", + "1. **Experiment with Dimensionality Reduction techniques** — Try to combine SINDy with different reductions techniques such as POD or autoencoders - or both of them, as done [here](https://www.sciencedirect.com/science/article/abs/pii/S0045793025003019). \n", + "\n", + "2. **Study Parameterized Systems** — Write your own SINDy model for parameterized problems.\n", + "\n", + "3. **...and many more!** — The possibilities are vast! Continue experimenting with advanced configurations, solvers, and features in PINA.\n", + "\n", + "For more resources and tutorials, check out the [PINA Documentation](https://mathlab.github.io/PINA/)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "pina", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/tutorial23/tutorial.py b/tutorials/tutorial23/tutorial.py new file mode 100644 index 000000000..0fdd0b95f --- /dev/null +++ b/tutorials/tutorial23/tutorial.py @@ -0,0 +1,341 @@ +#!/usr/bin/env python +# coding: utf-8 + +# # Tutorial: Data-driven System Identification with SINDy +# +# [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/mathLab/PINA/blob/master/tutorials/tutorial23/tutorial.ipynb) +# +# +# > ##### ⚠️ ***Before starting:*** +# > We assume you are already familiar with the concepts covered in the [Getting started with PINA](https://mathlab.github.io/PINA/_tutorial.html#getting-started-with-pina) tutorial. If not, we strongly recommend reviewing them before exploring this advanced topic. +# +# In this tutorial, we will demonstrate a typical use case of **PINA** for Data-driven system identification using SINDy. The tutorial is largely inspired by the paper [Discovering governing equations from data by sparse identification of nonlinear dynamical systems](dx.doi.org/10.1073/pnas.1517384113). +# +# Let's start by importing the useful modules: + +# In[ ]: + + +## routine needed to run the notebook on Google Colab +try: + import google.colab + + IN_COLAB = True +except: + IN_COLAB = False +if IN_COLAB: + get_ipython().system('pip install "pina-mathlab[tutorial]"') + +import torch +import numpy as np +import matplotlib.pyplot as plt +import warnings + +np.random.seed(0) +warnings.filterwarnings("ignore") + +from scipy.integrate import odeint +from pina import Trainer, LabelTensor +from pina.problem.zoo import SupervisedProblem +from pina.solver import SupervisedSolver +from pina.optim import TorchOptimizer +from pina.model import SINDy + + +# ## Data generation +# In this tutorial, we'll focus on the **identification** of a dynamical system starting only from a finite set of **snapshots**. +# More precisely, we'll assume that the dynamics is governed by dynamical system written as follows: +# $$\dot{\boldsymbol{x}}(t)=\boldsymbol{f}(\boldsymbol{x}(t)),$$ +# along with suitable initial conditions. +# For simplicity, we'll omit the argument of $\boldsymbol{x}$ from this point onward. +# +# Since $\boldsymbol{f}$ is unknown, we want to model it. +# While neural networks could be used to find an expression for $\boldsymbol{f}$, in certain contexts - for instance, to perform long-horizon forecasting - it might be useful to have an **explicit** set of equations describing it, which would also allow for a better degree of **interpretability** of our model. +# +# As a result, we use SINDy (introduced in [this paper](https://www.pnas.org/doi/full/10.1073/pnas.1517384113)), which we'll describe later on. +# Now, instead, we describe the system that is going to be considered in this tutorial: the **Lorenz** system. +# +# The Lorenz system is a set of three ordinary differential equations and is a simplified model of atmospheric convection. +# It is well-known because it can exhibit chaotic behavior, _i.e._, for given values of the parameters solutions are highly sensitive to small perturbations in the initial conditions, making forecasting extremely challenging. +# +# Mathematically speaking, we can write the Lorenz equations as +# $$ +# \begin{cases} +# \dot{x}=\sigma(y-x)\\ +# \dot{y}=x(\rho-z) - y\\ +# \dot{z}=xy-\beta z. +# \end{cases} +# $$ +# With $\sigma = 10,\, \rho = 28$, and $\beta=8/3$, the solutions trace out the famous butterfly-shaped Lorenz attractor. +# +# With the following lines of code, we just generate the dataset for SINDy and plot some trajectories. +# +# **Disclaimer**: of course, here we use the equations defining the Lorenz system just to generate the data. +# If we had access to the dynamical term $\boldsymbol{f}$, there would be no need to use SINDy. + +# In[ ]: + + +sigma, rho, beta = 10.0, 28.0, 8 / 3 + + +def lorenz(x, t): + dx = np.zeros(3) + dx[0] = sigma * (x[1] - x[0]) + dx[1] = x[0] * (rho - x[2]) - x[1] + dx[2] = x[0] * x[1] - beta * x[2] + return dx + + +n_ic_s = 200 # number of initial conditions +T = 1000 # number of timesteps +dt = 0.001 # timestep +t = np.linspace(0, (T - 1) * dt, T) +dim = 3 + +x0s = (np.random.rand(n_ic_s, dim) - 0.5) * 30.0 # Random initial conditions + +X = np.zeros((n_ic_s, T, dim)) +for i in range(n_ic_s): + X[i] = odeint(lorenz, x0s[i], t) # integrated trajectories + + +def plot_n_conditions(X, n_to_plot): + fig = plt.figure(figsize=(6, 5)) + ax = fig.add_subplot(111, projection="3d") + + for i in range(n_to_plot): + ax.plot(X[i, :, 0], X[i, :, 1], X[i, :, 2], lw=1) + + ax.set_xlabel("$x$") + ax.set_ylabel("$y$") + ax.set_zlabel("$z$") + + plt.tight_layout() + plt.show() + + +plot_n_conditions(X, n_ic_s) + + +# ## Sparse Identification of Nonlinear Dynamics +# The core idea of SINDy is to model $\boldsymbol f$ as a linear combination of functions in a library $\Theta$ of **candidate** functions. +# In other words, assume that we have $r$ functions which might be suitable to describe the system's dynamics (_e.g._, $x,\, y,\, x^2,\, xz,\, \dots,\,\sin(x)$, $\dots$). +# For each component of $\boldsymbol{f}$ at a given point $\boldsymbol{x}$, we want to write +# $$ +# \dot{x}_i = f_i(\boldsymbol{x}) = \sum_{k}\Theta(\boldsymbol{x})_{k}\xi_{k,i}, +# $$ +# with $\boldsymbol{\xi}_i\in\mathbb{R}^r$ a vector of **coefficients** telling us which terms are active in the expression of $f_i$. +# +# Since we are in a supervised setting, we assume that we have at our disposal the snapshot matrix $\boldsymbol{X}$ and a matrix $\dot{\boldsymbol{X}}$ containing time **derivatives** at the corresponding time instances. +# Then, we can just impose that the previous relation holds on the data at our disposal. +# That is, our optimization problem will read as follows: +# $$ +# \min_{\boldsymbol{\Xi}}\|\dot{\boldsymbol{X}}-\Theta(\boldsymbol{X})\boldsymbol{\Xi}\|_2^2. +# $$ +# +# Notice, however, that the solution to the previous equation might not be **sparse**, as there might be many non-zero terms in it. +# In practice, many physical systems are described by a parsimonious and **interpretable** set of equations. +# Thus, we also impose a $L^1$ **penalization** on the model weights, encouraging them to be small in magnitude and trying to enforce sparsity. +# The final loss is then expressed as +# +# $$ +# \min_{\boldsymbol{\Xi}}\bigl(\|\dot{\boldsymbol{X}}-\Theta(\boldsymbol{X})\boldsymbol{\Xi}\|_2^2 + \lambda\|\boldsymbol{\Xi}\|_1\bigr), +# $$ +# with $\lambda\in\mathbb{R}^+$ a hyperparameter. +# +# Let us begin by computing the time derivatives of the data. +# Of course, usually we do not have access to the exact time derivatives of the system, meaning that $\dot{\boldsymbol{X}}$ needs to be **approximated**. +# Here we do it using a simple Finite Difference (FD) scheme, but [more sophisticated ideas](https://arxiv.org/abs/2505.16058) could be considered. + +# In[ ]: + + +dXdt = np.gradient(X, t, axis=1, edge_order=2) +X_torch = torch.tensor(X, dtype=torch.float32).reshape( + (-1, dim) +) # X_torch has shape (B, dim) +dXdt_torch = torch.tensor(dXdt, dtype=torch.float32).reshape((-1, dim)) + + +# We create two `LabelTensor` objects to keep everything as readable as possible. + +# In[ ]: + + +X_torch = LabelTensor(X_torch, ["x", "y", "z"]) +dXdt_torch = LabelTensor(dXdt_torch, ["dxdt", "dydt", "dzdt"]) + + +# Now we define the **library of candidate functions**. +# In our case, it will consist of polynomials of degree at most $2$ in the state variables. +# While the `SINDy` class in **PINA** expects a **list** of callables, here we define also dictionary, as its keys will be used to print the retrieved equations, enhancing the model interpretability and allowing it to be compared to the original Lorenz system. +# Notice how readable the code is as a result of the use of the `LabelTensor` class! + +# In[ ]: + + +function_dict = { + "1": lambda u: torch.ones(u.shape[0], 1, device=u.device), # 1 + "x": lambda u: u["x"], # x + "y": lambda u: u["y"], # y + "z": lambda u: u["z"], # z + "x^2": lambda u: u["x"].pow(2), # x^2 + "y^2": lambda u: u["y"].pow(2), # y^2 + "z^2": lambda u: u["z"].pow(2), # z^2 + "xy": lambda u: u["x"] * u["y"], # xy + "xz": lambda u: u["x"] * u["z"], # xz + "yz": lambda u: u["y"] * u["z"], # yz +} + +function_library = [ + _function for _function in function_dict.values() +] # input of the model constructor + + +# ## Training with PINA +# We are now ready to train our model! We can use **PINA** to train the model, following the workflow from previous tutorials. +# First, we need to define the problem. In this case, we will use the [`SupervisedProblem`](https://mathlab.github.io/PINA/_rst/problem/zoo/supervised_problem.html#module-pina.problem.zoo.supervised_problem), which expects: +# +# - **Input**: the state variables tensor $\boldsymbol{X}$ containing all the collected snapshots. +# - **Output**: the corresponding time derivatives $\dot{\boldsymbol{X}}$. + +# In[ ]: + + +_lambda = 1e-3 + +model = SINDy(function_library, dim) +problem = SupervisedProblem(X_torch, dXdt_torch) + + +# Finally, we will use the `SupervisedSolver` to perform the training as we're dealing with a supervised problem. +# +# Recall that we should use $L^1$-regularization on the model's weights to ensure sparsity. For the ease of implementation, we adopt $L^2$ regularization, which is less common in SINDy literature but will suffice in our case. +# Additionally, more refined strategies could be used, for instance pruning coefficients below a certain **threshold** at every fixed number of epochs, but here we avoid further complications. + +# In[ ]: + + +solver = SupervisedSolver( + problem, + model=model, + optimizer=TorchOptimizer(torch.optim.Adam, lr=1e-3, weight_decay=_lambda), + use_lt=False, +) + + +# Training is performed as usual using the **`Trainer`** API. + +# In[ ]: + + +trainer = Trainer( + solver, + accelerator="cpu", + max_epochs=150, + train_size=0.8, + val_size=0.1, + test_size=0.1, + shuffle=True, + batch_size=512, + enable_model_summary=False, +) + +trainer.train() + + +# Now we'll print the identified equations and compare them with the original ones. +# +# Before going on, we underline that after training there might be many coefficients that are small, yet still non-zero. +# It is common for SINDy practitioners to interpret these coefficients as noise in the model and prune them. +# This is typically done by fixing a threshold $\tau\in\mathbb{R}^+$ and setting to $0$ all those $\xi_{i,j}$ such that $|\xi_{i,j}|<\tau$. +# +# In the following cell, we also define a function to print the identified model. + +# In[ ]: + + +def print_coefficients(model, function_names, tau, vars=None): + with torch.no_grad(): + Xi = model.coefficients.data.cpu().numpy() + + library_dim, dim = Xi.shape + + for j in range(dim): + terms = [] + for i in range(library_dim): + coefficient = Xi[i, j] + if ( + abs(coefficient) > tau + ): # do not print coefficients that are going to be pruned + function_name = function_names[i] + terms.append(f"{coefficient:+.2f} * {function_name} ") + + equation = " ".join(terms) + + if not equation: + equation = "0" + if vars is not None: + print(f"d{vars[j]}/dt = {equation}") + else: + print(f"d(State_{j+1})/dt = {equation}") + + +tau = 1e-1 + +print_coefficients(model, list(function_dict.keys()), tau, vars=["x", "y", "z"]) + +with torch.no_grad(): # prune coefficients + mask = torch.abs(model.coefficients) >= tau + model.coefficients.data *= mask + + +# Good! While there are small errors on some of the coefficients, the active terms in the library have been correctly identified (recall that the original system reads as follows): +# $$ +# \begin{cases} +# \dot{x}=-10x+10y\\ +# \dot{y}=28x - y-xz\\ +# \dot{z}=-\frac{8}{3} z+xy. +# \end{cases} +# $$ +# +# That's a good result, especially considering that we did not perform tuning on the weight decay hyperparameter $\lambda$ and did not really care much about other optimization parameters. +# +# Let's plot a few trajectories! + +# In[ ]: + + +def SINDy_equations(x, t): # we need a numpy array for odeint + with torch.no_grad(): + x_torch = torch.tensor(x, dtype=torch.float32).unsqueeze( + 0 + ) # shape (1, dim) + x_torch = LabelTensor(x_torch, ["x", "y", "z"]) + dx = model(x_torch).squeeze(0) + return dx.numpy() + + +n_ic_s_test = 50 +x0s = (np.random.rand(n_ic_s_test, dim) - 0.5) * 30.0 + +X_sim = np.zeros((n_ic_s_test, T, dim)) +for i in range(n_ic_s_test): + X_sim[i] = odeint(SINDy_equations, x0s[i], t) + +plot_n_conditions(X_sim, n_ic_s_test) + + +# Great! We can see that the qualitative behavior of the system is really close to the real one. +# +# ## What's next? +# Congratulations on completing the introductory tutorial on **Data-driven System Identification with SINDy**! Now that you have a solid foundation, here are a few directions to explore: +# +# 1. **Experiment with Dimensionality Reduction techniques** — Try to combine SINDy with different reductions techniques such as POD or autoencoders - or both of them, as done [here](https://www.sciencedirect.com/science/article/abs/pii/S0045793025003019). +# +# 2. **Study Parameterized Systems** — Write your own SINDy model for parameterized problems. +# +# 3. **...and many more!** — The possibilities are vast! Continue experimenting with advanced configurations, solvers, and features in PINA. +# +# For more resources and tutorials, check out the [PINA Documentation](https://mathlab.github.io/PINA/).