diff --git a/docs/tutorials/classification.ipynb b/docs/tutorials/classification.ipynb index 21229648e..0bba08deb 100644 --- a/docs/tutorials/classification.ipynb +++ b/docs/tutorials/classification.ipynb @@ -32,8 +32,8 @@ "metadata": { "collapsed": false }, - "outputs": [], - "execution_count": null + "execution_count": null, + "outputs": [] }, { "cell_type": "markdown", @@ -55,8 +55,8 @@ "metadata": { "collapsed": false }, - "outputs": [], - "execution_count": null + "execution_count": null, + "outputs": [] }, { "cell_type": "markdown", @@ -80,8 +80,8 @@ "metadata": { "collapsed": false }, - "outputs": [], - "execution_count": null + "execution_count": null, + "outputs": [] }, { "cell_type": "markdown", @@ -98,8 +98,8 @@ "metadata": { "collapsed": false }, - "outputs": [], - "execution_count": null + "execution_count": null, + "outputs": [] }, { "cell_type": "markdown", @@ -118,8 +118,8 @@ "metadata": { "collapsed": false }, - "outputs": [], - "execution_count": null + "execution_count": null, + "outputs": [] }, { "cell_type": "markdown", @@ -139,8 +139,8 @@ "metadata": { "collapsed": false }, - "outputs": [], - "execution_count": null + "execution_count": null, + "outputs": [] }, { "cell_type": "markdown", @@ -166,8 +166,8 @@ "metadata": { "collapsed": false }, - "outputs": [], - "execution_count": null + "execution_count": null, + "outputs": [] }, { "cell_type": "markdown", @@ -189,8 +189,8 @@ "metadata": { "collapsed": false }, - "outputs": [], - "execution_count": null + "execution_count": null, + "outputs": [] } ], "metadata": { diff --git a/docs/tutorials/convolutional_neural_network_for_image_classification.ipynb b/docs/tutorials/convolutional_neural_network_for_image_classification.ipynb index 1ca070ee6..ee15eaa03 100644 --- a/docs/tutorials/convolutional_neural_network_for_image_classification.ipynb +++ b/docs/tutorials/convolutional_neural_network_for_image_classification.ipynb @@ -52,12 +52,12 @@ "metadata": { "collapsed": true }, - "outputs": [], "source": [ "from safeds.data.image.containers import ImageList\n", "\n", "images, filepaths = ImageList.from_files(\"data/shapes\", return_filenames=True)" - ] + ], + "outputs": [] }, { "cell_type": "markdown", @@ -71,7 +71,6 @@ }, { "cell_type": "code", - "outputs": [], "source": [ "import re\n", "from safeds.data.tabular.containers import Column\n", @@ -85,7 +84,8 @@ "collapsed": false }, "id": "66dcf95a3fa51f23", - "execution_count": null + "execution_count": null, + "outputs": [] }, { "cell_type": "markdown", @@ -99,7 +99,6 @@ }, { "cell_type": "code", - "outputs": [], "source": [ "from safeds.data.labeled.containers import ImageDataset\n", "\n", @@ -109,7 +108,8 @@ "collapsed": false }, "id": "32056ddf5396e070", - "execution_count": null + "execution_count": null, + "outputs": [] }, { "cell_type": "markdown", @@ -131,7 +131,6 @@ }, { "cell_type": "code", - "outputs": [], "source": [ "from safeds.ml.nn.layers import (Convolutional2DLayer, FlattenLayer,\n", " ForwardLayer, MaxPooling2DLayer)\n", @@ -142,15 +141,16 @@ " Convolutional2DLayer(output_channel=32, kernel_size=3, padding=1),\n", " MaxPooling2DLayer(kernel_size=2, stride=2),\n", " FlattenLayer(),\n", - " ForwardLayer(output_size=128),\n", - " ForwardLayer(output_size=3),\n", + " ForwardLayer(neuron_count=128),\n", + " ForwardLayer(neuron_count=3),\n", "]" ], "metadata": { "collapsed": false }, "id": "806a8091249d533a", - "execution_count": null + "execution_count": null, + "outputs": [] }, { "cell_type": "markdown", @@ -162,7 +162,6 @@ }, { "cell_type": "code", - "outputs": [], "source": [ "from safeds.ml.nn import NeuralNetworkClassifier\n", "from safeds.ml.nn.converters import InputConversionImageToColumn\n", @@ -176,7 +175,8 @@ "collapsed": false }, "id": "af68cc0d32655d32", - "execution_count": null + "execution_count": null, + "outputs": [] }, { "cell_type": "markdown", @@ -198,7 +198,6 @@ }, { "cell_type": "code", - "outputs": [], "source": [ "cnn_fitted = cnn.fit(dataset, epoch_size=32, batch_size=16)" ], @@ -206,7 +205,8 @@ "collapsed": false }, "id": "381627a94d500675", - "execution_count": null + "execution_count": null, + "outputs": [] }, { "cell_type": "markdown", @@ -220,7 +220,6 @@ }, { "cell_type": "code", - "outputs": [], "source": [ "prediction = cnn_fitted.predict(dataset.get_input())" ], @@ -228,7 +227,8 @@ "collapsed": false }, "id": "62f63dd68362c8b7", - "execution_count": null + "execution_count": null, + "outputs": [] }, { "cell_type": "markdown", @@ -242,7 +242,6 @@ }, { "cell_type": "code", - "outputs": [], "source": [ "shuffled_prediction = prediction.shuffle()" ], @@ -250,7 +249,8 @@ "collapsed": false }, "id": "779277d73e30554d", - "execution_count": null + "execution_count": null, + "outputs": [] }, { "cell_type": "markdown", @@ -264,7 +264,6 @@ }, { "cell_type": "code", - "outputs": [], "source": [ "shuffled_prediction.get_input().remove_image_by_index(list(range(9, len(prediction))))" ], @@ -272,7 +271,8 @@ "collapsed": false }, "id": "a5ddbbfba41aa7f", - "execution_count": null + "execution_count": null, + "outputs": [] }, { "cell_type": "markdown", @@ -286,7 +286,6 @@ }, { "cell_type": "code", - "outputs": [], "source": [ "shuffled_prediction.get_output().to_list()[0:9]" ], @@ -294,7 +293,8 @@ "collapsed": false }, "id": "7081595d7100fb42", - "execution_count": null + "execution_count": null, + "outputs": [] } ], "metadata": { diff --git a/docs/tutorials/machine_learning.ipynb b/docs/tutorials/machine_learning.ipynb index 2eec3453a..8f7c46cc2 100644 --- a/docs/tutorials/machine_learning.ipynb +++ b/docs/tutorials/machine_learning.ipynb @@ -35,8 +35,8 @@ "metadata": { "collapsed": false }, - "outputs": [], - "execution_count": null + "execution_count": null, + "outputs": [] }, { "cell_type": "markdown", @@ -60,8 +60,8 @@ "metadata": { "collapsed": false }, - "outputs": [], - "execution_count": null + "execution_count": null, + "outputs": [] }, { "cell_type": "markdown", @@ -87,8 +87,8 @@ "metadata": { "collapsed": false }, - "outputs": [], - "execution_count": null + "execution_count": null, + "outputs": [] }, { "cell_type": "markdown", @@ -158,8 +158,8 @@ "metadata": { "collapsed": false }, - "outputs": [], - "execution_count": null + "execution_count": null, + "outputs": [] }, { "cell_type": "markdown", @@ -199,8 +199,8 @@ "metadata": { "collapsed": false }, - "outputs": [], - "execution_count": null + "execution_count": null, + "outputs": [] }, { "cell_type": "markdown", @@ -240,8 +240,8 @@ "metadata": { "collapsed": false }, - "outputs": [], - "execution_count": null + "execution_count": null, + "outputs": [] }, { "cell_type": "markdown", @@ -281,8 +281,8 @@ "metadata": { "collapsed": false }, - "outputs": [], - "execution_count": null + "execution_count": null, + "outputs": [] }, { "cell_type": "markdown", diff --git a/src/safeds/data/labeled/containers/_tabular_dataset.py b/src/safeds/data/labeled/containers/_tabular_dataset.py index e95c182ed..5f2c9db19 100644 --- a/src/safeds/data/labeled/containers/_tabular_dataset.py +++ b/src/safeds/data/labeled/containers/_tabular_dataset.py @@ -196,8 +196,8 @@ def _into_dataloader_with_classes(self, batch_size: int, num_of_classes: int) -> if num_of_classes <= 2: return DataLoader( dataset=_create_dataset( - torch.Tensor(self.features._data_frame.to_numpy()).to(_get_device()), - torch.Tensor(self.target._series.to_numpy()).to(_get_device()).unsqueeze(dim=-1), + self.features._data_frame.to_torch().to(_get_device()), + self.target._series.to_torch().to(_get_device()).unsqueeze(dim=-1), ), batch_size=batch_size, shuffle=True, @@ -206,9 +206,9 @@ def _into_dataloader_with_classes(self, batch_size: int, num_of_classes: int) -> else: return DataLoader( dataset=_create_dataset( - torch.Tensor(self.features._data_frame.to_numpy()).to(_get_device()), + self.features._data_frame.to_torch().to(_get_device()), torch.nn.functional.one_hot( - torch.LongTensor(self.target._series.to_numpy()).to(_get_device()), + self.target._series.to_torch().to(_get_device()), num_classes=num_of_classes, ), ), diff --git a/src/safeds/data/tabular/transformation/__init__.py b/src/safeds/data/tabular/transformation/__init__.py index 66db09f28..b7f19d22e 100644 --- a/src/safeds/data/tabular/transformation/__init__.py +++ b/src/safeds/data/tabular/transformation/__init__.py @@ -18,7 +18,7 @@ __name__, { "Discretizer": "._discretizer:Discretizer", - "InvertibleTableTransformer": "._table_transformer:InvertibleTableTransformer", + "InvertibleTableTransformer": "._invertible_table_transformer:InvertibleTableTransformer", "LabelEncoder": "._label_encoder:LabelEncoder", "OneHotEncoder": "._one_hot_encoder:OneHotEncoder", "RangeScaler": "._range_scaler:RangeScaler", diff --git a/src/safeds/data/tabular/transformation/_discretizer.py b/src/safeds/data/tabular/transformation/_discretizer.py index b1fe56275..a40a7f306 100644 --- a/src/safeds/data/tabular/transformation/_discretizer.py +++ b/src/safeds/data/tabular/transformation/_discretizer.py @@ -63,10 +63,12 @@ def __hash__(self) -> int: @property def is_fitted(self) -> bool: + """Whether the transformer is fitted.""" return self._wrapped_transformer is not None @property def bin_count(self) -> int: + """The number of bins to be created.""" return self._bin_count # ------------------------------------------------------------------------------------------------------------------ diff --git a/src/safeds/data/tabular/transformation/_invertible_table_transformer.py b/src/safeds/data/tabular/transformation/_invertible_table_transformer.py index 88d640477..7b0caefc8 100644 --- a/src/safeds/data/tabular/transformation/_invertible_table_transformer.py +++ b/src/safeds/data/tabular/transformation/_invertible_table_transformer.py @@ -1,6 +1,6 @@ from __future__ import annotations -from abc import abstractmethod +from abc import ABC, abstractmethod from typing import TYPE_CHECKING from ._table_transformer import TableTransformer @@ -9,7 +9,7 @@ from safeds.data.tabular.containers import Table -class InvertibleTableTransformer(TableTransformer): +class InvertibleTableTransformer(TableTransformer, ABC): """A `TableTransformer` that can also undo the learned transformation after it has been applied.""" @abstractmethod diff --git a/src/safeds/data/tabular/transformation/_label_encoder.py b/src/safeds/data/tabular/transformation/_label_encoder.py index 1cdf980c2..78545f088 100644 --- a/src/safeds/data/tabular/transformation/_label_encoder.py +++ b/src/safeds/data/tabular/transformation/_label_encoder.py @@ -60,6 +60,7 @@ def __hash__(self) -> int: @property def is_fitted(self) -> bool: + """Whether the transformer is fitted.""" return self._mapping is not None and self._inverse_mapping is not None @property diff --git a/src/safeds/data/tabular/transformation/_one_hot_encoder.py b/src/safeds/data/tabular/transformation/_one_hot_encoder.py index c3035b956..0df5be8c9 100644 --- a/src/safeds/data/tabular/transformation/_one_hot_encoder.py +++ b/src/safeds/data/tabular/transformation/_one_hot_encoder.py @@ -108,6 +108,7 @@ def __hash__(self) -> int: @property def is_fitted(self) -> bool: + """Whether the transformer is fitted.""" return self._mapping is not None @property diff --git a/src/safeds/data/tabular/transformation/_range_scaler.py b/src/safeds/data/tabular/transformation/_range_scaler.py index c58cb64b0..c54b60dc8 100644 --- a/src/safeds/data/tabular/transformation/_range_scaler.py +++ b/src/safeds/data/tabular/transformation/_range_scaler.py @@ -71,6 +71,7 @@ def __hash__(self) -> int: @property def is_fitted(self) -> bool: + """Whether the transformer is fitted.""" return self._data_min is not None and self._data_max is not None @property diff --git a/src/safeds/data/tabular/transformation/_simple_imputer.py b/src/safeds/data/tabular/transformation/_simple_imputer.py index 63e2e1381..89d078f7d 100644 --- a/src/safeds/data/tabular/transformation/_simple_imputer.py +++ b/src/safeds/data/tabular/transformation/_simple_imputer.py @@ -122,6 +122,7 @@ def __hash__(self) -> int: @property def is_fitted(self) -> bool: + """Whether the transformer is fitted.""" return self._replacement is not None @property diff --git a/src/safeds/data/tabular/transformation/_standard_scaler.py b/src/safeds/data/tabular/transformation/_standard_scaler.py index 969c04e0a..eacaf30ec 100644 --- a/src/safeds/data/tabular/transformation/_standard_scaler.py +++ b/src/safeds/data/tabular/transformation/_standard_scaler.py @@ -44,6 +44,7 @@ def __hash__(self) -> int: @property def is_fitted(self) -> bool: + """Whether the transformer is fitted.""" return self._data_mean is not None and self._data_standard_deviation is not None # ------------------------------------------------------------------------------------------------------------------ diff --git a/src/safeds/ml/nn/_internal_model.py b/src/safeds/ml/nn/_internal_model.py new file mode 100644 index 000000000..cb1105503 --- /dev/null +++ b/src/safeds/ml/nn/_internal_model.py @@ -0,0 +1,60 @@ +# The class must not be nested inside a function, since pickle cannot serialize local classes. Because of this, the +# slow import of torch must be on the global level. To still evaluate the torch import lazily, the class is moved to a +# separate file. + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from torch import Tensor, nn # slow import + +from safeds._config import _init_default_device +from safeds.ml.nn.converters._input_converter_image import _InputConversionImage +from safeds.ml.nn.layers import FlattenLayer, Layer +from safeds.ml.nn.layers._pooling2d_layer import _Pooling2DLayer + +if TYPE_CHECKING: + from safeds.ml.nn.converters import InputConversion + from safeds.ml.nn.typing import ModelImageSize + + +# Use torch.compile once the following issues are resolved: +# - https://github.com/pytorch/pytorch/issues/120233 (Python 3.12 support) +# - https://github.com/triton-lang/triton/issues/1640 (Windows support) +class _InternalModel(nn.Module): + def __init__(self, input_conversion: InputConversion, layers: list[Layer], is_for_classification: bool) -> None: + super().__init__() + + _init_default_device() + + self._layer_list = layers + internal_layers = [] + previous_output_size = input_conversion._data_size + + for layer in layers: + if previous_output_size is not None: + layer._set_input_size(previous_output_size) + elif isinstance(input_conversion, _InputConversionImage): + layer._set_input_size(input_conversion._data_size) + if isinstance(layer, FlattenLayer | _Pooling2DLayer): + internal_layers.append(layer._get_internal_layer()) + else: + internal_layers.append(layer._get_internal_layer(activation_function="relu")) + previous_output_size = layer.output_size + + if is_for_classification: + internal_layers.pop() + if isinstance(layers[-1].output_size, int) and layers[-1].output_size > 2: + internal_layers.append(layers[-1]._get_internal_layer(activation_function="none")) + else: + internal_layers.append(layers[-1]._get_internal_layer(activation_function="sigmoid")) + self._pytorch_layers = nn.Sequential(*internal_layers) + + @property + def input_size(self) -> int | ModelImageSize: + return self._layer_list[0].input_size + + def forward(self, x: Tensor) -> Tensor: + for layer in self._pytorch_layers: + x = layer(x) + return x diff --git a/src/safeds/ml/nn/_model.py b/src/safeds/ml/nn/_model.py index 754b29a83..710c4df62 100644 --- a/src/safeds/ml/nn/_model.py +++ b/src/safeds/ml/nn/_model.py @@ -12,7 +12,6 @@ from safeds.data.tabular.transformation import OneHotEncoder from safeds.exceptions import ( FeatureDataMismatchError, - InputSizeError, InvalidModelStructureError, ModelNotFittedError, ) @@ -33,7 +32,7 @@ if TYPE_CHECKING: from collections.abc import Callable - from torch import Tensor, nn + from torch import nn from torch.nn import Module from transformers.image_processing_utils import BaseImageProcessor @@ -207,6 +206,8 @@ def fit( import torch from torch import nn + from ._internal_model import _InternalModel # Slow import on global level + _init_default_device() if not self._input_conversion._is_fit_data_valid(train_data): @@ -216,12 +217,14 @@ def fit( _check_bounds("batch_size", batch_size, lower_bound=_ClosedBound(1)) copied_model = copy.deepcopy(self) - copied_model._model = _create_internal_model(self._input_conversion, self._layers, is_for_classification=False) + # TODO: How is this supposed to work with pre-trained models? Should the old weights be kept or discarded? + copied_model._model = _InternalModel(self._input_conversion, self._layers, is_for_classification=False) copied_model._input_size = copied_model._model.input_size copied_model._batch_size = batch_size - if copied_model._input_conversion._data_size != copied_model._input_size: - raise InputSizeError(copied_model._input_conversion._data_size, copied_model._input_size) + # TODO: Re-enable or remove depending on how the above TODO is resolved + # if copied_model._input_conversion._data_size != copied_model._input_size: + # raise InputSizeError(copied_model._input_conversion._data_size, copied_model._input_size) dataloader = copied_model._input_conversion._data_conversion_fit(train_data, copied_model._batch_size) @@ -500,6 +503,8 @@ def fit( import torch from torch import nn + from ._internal_model import _InternalModel # Slow import on global level + _init_default_device() if not self._input_conversion._is_fit_data_valid(train_data): @@ -509,12 +514,14 @@ def fit( _check_bounds("batch_size", batch_size, lower_bound=_ClosedBound(1)) copied_model = copy.deepcopy(self) - copied_model._model = _create_internal_model(self._input_conversion, self._layers, is_for_classification=True) + # TODO: How is this supposed to work with pre-trained models? Should the old weights be kept or discarded? + copied_model._model = _InternalModel(self._input_conversion, self._layers, is_for_classification=True) copied_model._batch_size = batch_size copied_model._input_size = copied_model._model.input_size - if copied_model._input_conversion._data_size != copied_model._input_size: - raise InputSizeError(copied_model._input_conversion._data_size, copied_model._input_size) + # TODO: Re-enable or remove depending on how the above TODO is resolved + # if copied_model._input_conversion._data_size != copied_model._input_size: + # raise InputSizeError(copied_model._input_conversion._data_size, copied_model._input_size) dataloader = copied_model._input_conversion._data_conversion_fit( train_data, @@ -614,53 +621,3 @@ def input_size(self) -> int | ModelImageSize | None: """The input size of the model.""" # TODO: raise if not fitted, don't return None return self._input_size - - -def _create_internal_model( - input_conversion: InputConversion[IFT, IPT], - layers: list[Layer], - is_for_classification: bool, -) -> nn.Module: - from torch import nn - - _init_default_device() - - class _InternalModel(nn.Module): - def __init__(self, layers: list[Layer], is_for_classification: bool) -> None: - super().__init__() - self._layer_list = layers - internal_layers = [] - previous_output_size = None - - for layer in layers: - if previous_output_size is not None: - layer._set_input_size(previous_output_size) - elif isinstance(input_conversion, _InputConversionImage): - layer._set_input_size(input_conversion._data_size) - if isinstance(layer, FlattenLayer | _Pooling2DLayer): - internal_layers.append(layer._get_internal_layer()) - else: - internal_layers.append(layer._get_internal_layer(activation_function="relu")) - previous_output_size = layer.output_size - - if is_for_classification: - internal_layers.pop() - if isinstance(layers[-1].output_size, int) and layers[-1].output_size > 2: - internal_layers.append(layers[-1]._get_internal_layer(activation_function="none")) - else: - internal_layers.append(layers[-1]._get_internal_layer(activation_function="sigmoid")) - self._pytorch_layers = nn.Sequential(*internal_layers) - - @property - def input_size(self) -> int | ModelImageSize: - return self._layer_list[0].input_size - - def forward(self, x: Tensor) -> Tensor: - for layer in self._pytorch_layers: - x = layer(x) - return x - - # Use torch.compile once the following issues are resolved: - # - https://github.com/pytorch/pytorch/issues/120233 (Python 3.12 support) - # - https://github.com/triton-lang/triton/issues/1640 (Windows support) - return _InternalModel(layers, is_for_classification) diff --git a/src/safeds/ml/nn/layers/_convolutional2d_layer.py b/src/safeds/ml/nn/layers/_convolutional2d_layer.py index db03b22b6..dd42f2d97 100644 --- a/src/safeds/ml/nn/layers/_convolutional2d_layer.py +++ b/src/safeds/ml/nn/layers/_convolutional2d_layer.py @@ -4,82 +4,14 @@ import sys from typing import TYPE_CHECKING, Any, Literal -from safeds._config import _init_default_device from safeds._utils import _structural_hash from ._layer import Layer if TYPE_CHECKING: - from torch import Tensor, nn - - from safeds.ml.nn.typing import ModelImageSize - - -def _create_internal_model( - input_size: int, - output_size: int, - kernel_size: int, - activation_function: Literal["sigmoid", "relu", "softmax"], - padding: int, - stride: int, - transpose: bool, - output_padding: int = 0, -) -> nn.Module: from torch import nn - _init_default_device() - - class _InternalLayer(nn.Module): - def __init__( - self, - input_size: int, - output_size: int, - kernel_size: int, - activation_function: Literal["sigmoid", "relu", "softmax"], - padding: int, - stride: int, - transpose: bool, - output_padding: int, - ): - super().__init__() - if transpose: - self._layer = nn.ConvTranspose2d( - in_channels=input_size, - out_channels=output_size, - kernel_size=kernel_size, - padding=padding, - stride=stride, - output_padding=output_padding, - ) - else: - self._layer = nn.Conv2d( - in_channels=input_size, - out_channels=output_size, - kernel_size=kernel_size, - padding=padding, - stride=stride, - ) - match activation_function: - case "sigmoid": - self._fn = nn.Sigmoid() - case "relu": - self._fn = nn.ReLU() - case "softmax": - self._fn = nn.Softmax() - - def forward(self, x: Tensor) -> Tensor: - return self._fn(self._layer(x)) - - return _InternalLayer( - input_size, - output_size, - kernel_size, - activation_function, - padding, - stride, - transpose, - output_padding, - ) + from safeds.ml.nn.typing import ModelImageSize class Convolutional2DLayer(Layer): @@ -107,6 +39,8 @@ def __init__(self, output_channel: int, kernel_size: int, *, stride: int = 1, pa self._output_size: ModelImageSize | None = None def _get_internal_layer(self, **kwargs: Any) -> nn.Module: + from ._internal_layers import _InternalConvolutional2DLayer # slow import on global level + if self._input_size is None: raise ValueError( "The input_size is not yet set. The internal layer can only be created when the input_size is set.", @@ -121,7 +55,7 @@ def _get_internal_layer(self, **kwargs: Any) -> nn.Module: ) else: activation_function: Literal["sigmoid", "relu", "softmax"] = kwargs["activation_function"] - return _create_internal_model( + return _InternalConvolutional2DLayer( self._input_size.channel, self._output_channel, self._kernel_size, @@ -129,6 +63,7 @@ def _get_internal_layer(self, **kwargs: Any) -> nn.Module: self._padding, self._stride, transpose=False, + output_padding=0, ) @property @@ -158,7 +93,7 @@ def output_size(self) -> ModelImageSize: Returns ------- result: - The Number of Neurons in this layer. + The number of neurons in this layer. Raises ------ @@ -191,14 +126,6 @@ def _set_input_size(self, input_size: int | ModelImageSize) -> None: self._output_size = None def __hash__(self) -> int: - """ - Return a deterministic hash value for this convolutional 2d layer. - - Returns - ------- - hash: - the hash value - """ return _structural_hash( self._output_channel, self._kernel_size, @@ -209,19 +136,6 @@ def __hash__(self) -> int: ) def __eq__(self, other: object) -> bool: - """ - Compare two convolutional 2d layer. - - Parameters - ---------- - other: - The convolutional 2d layer to compare to. - - Returns - ------- - equals: - Whether the two convolutional 2d layer are the same. - """ if not isinstance(other, Convolutional2DLayer) or isinstance(other, ConvolutionalTranspose2DLayer): return NotImplemented return (self is other) or ( @@ -234,14 +148,6 @@ def __eq__(self, other: object) -> bool: ) def __sizeof__(self) -> int: - """ - Return the complete size of this object. - - Returns - ------- - size: - Size of this object in bytes. - """ return ( sys.getsizeof(self._output_channel) + sys.getsizeof(self._kernel_size) @@ -283,6 +189,8 @@ def __init__( self._output_padding = output_padding def _get_internal_layer(self, **kwargs: Any) -> nn.Module: + from ._internal_layers import _InternalConvolutional2DLayer # slow import on global level + if self._input_size is None: raise ValueError( "The input_size is not yet set. The internal layer can only be created when the input_size is set.", @@ -297,7 +205,7 @@ def _get_internal_layer(self, **kwargs: Any) -> nn.Module: ) else: activation_function: Literal["sigmoid", "relu", "softmax"] = kwargs["activation_function"] - return _create_internal_model( + return _InternalConvolutional2DLayer( self._input_size.channel, self._output_channel, self._kernel_size, @@ -336,30 +244,9 @@ def output_size(self) -> ModelImageSize: return self._output_size def __hash__(self) -> int: - """ - Return a deterministic hash value for this convolutional transpose 2d layer. - - Returns - ------- - hash: - the hash value - """ return _structural_hash(super().__hash__(), self._output_padding) def __eq__(self, other: object) -> bool: - """ - Compare two convolutional transpose 2d layer. - - Parameters - ---------- - other: - The convolutional transpose 2d layer to compare to. - - Returns - ------- - equals: - Whether the two convolutional transpose 2d layer are the same. - """ if not isinstance(other, ConvolutionalTranspose2DLayer): return NotImplemented return (self is other) or ( @@ -373,12 +260,4 @@ def __eq__(self, other: object) -> bool: ) def __sizeof__(self) -> int: - """ - Return the complete size of this object. - - Returns - ------- - size: - Size of this object in bytes. - """ return sys.getsizeof(self._output_padding) + super().__sizeof__() diff --git a/src/safeds/ml/nn/layers/_flatten_layer.py b/src/safeds/ml/nn/layers/_flatten_layer.py index 4bbacc995..a84551c2b 100644 --- a/src/safeds/ml/nn/layers/_flatten_layer.py +++ b/src/safeds/ml/nn/layers/_flatten_layer.py @@ -3,32 +3,15 @@ import sys from typing import TYPE_CHECKING, Any -from safeds._config import _init_default_device from safeds._utils import _structural_hash from safeds.ml.nn.typing import ConstantImageSize from ._layer import Layer if TYPE_CHECKING: - from torch import Tensor, nn - - from safeds.ml.nn.typing import ModelImageSize - - -def _create_internal_model() -> nn.Module: from torch import nn - _init_default_device() - - class _InternalLayer(nn.Module): - def __init__(self) -> None: - super().__init__() - self._layer = nn.Flatten() - - def forward(self, x: Tensor) -> Tensor: - return self._layer(x) - - return _InternalLayer() + from safeds.ml.nn.typing import ModelImageSize class FlattenLayer(Layer): @@ -39,7 +22,9 @@ def __init__(self) -> None: self._output_size: int | None = None def _get_internal_layer(self, **kwargs: Any) -> nn.Module: # noqa: ARG002 - return _create_internal_model() + from ._internal_layers import _InternalFlattenLayer # Slow import on global level + + return _InternalFlattenLayer() @property def input_size(self) -> ModelImageSize: @@ -48,7 +33,7 @@ def input_size(self) -> ModelImageSize: Returns ------- - result : + result: The amount of values being passed into this layer. Raises @@ -67,8 +52,8 @@ def output_size(self) -> int: Returns ------- - result : - The Number of Neurons in this layer. + result: + The number of neurons in this layer. Raises ------ @@ -92,41 +77,12 @@ def _set_input_size(self, input_size: int | ModelImageSize) -> None: self._output_size = None def __hash__(self) -> int: - """ - Return a deterministic hash value for this flatten layer. - - Returns - ------- - hash: - the hash value - """ return _structural_hash(self._input_size, self._output_size) def __eq__(self, other: object) -> bool: - """ - Compare two flatten layer. - - Parameters - ---------- - other: - The flatten layer to compare to. - - Returns - ------- - equals: - Whether the two flatten layer are the same. - """ if not isinstance(other, FlattenLayer): return NotImplemented return (self is other) or (self._input_size == other._input_size and self._output_size == other._output_size) def __sizeof__(self) -> int: - """ - Return the complete size of this object. - - Returns - ------- - size: - Size of this object in bytes. - """ return sys.getsizeof(self._input_size) + sys.getsizeof(self._output_size) diff --git a/src/safeds/ml/nn/layers/_forward_layer.py b/src/safeds/ml/nn/layers/_forward_layer.py index 7b50f85e3..e420b78ec 100644 --- a/src/safeds/ml/nn/layers/_forward_layer.py +++ b/src/safeds/ml/nn/layers/_forward_layer.py @@ -2,7 +2,6 @@ from typing import TYPE_CHECKING, Any -from safeds._config import _init_default_device from safeds._utils import _structural_hash from safeds._validation import _check_bounds, _ClosedBound from safeds.ml.nn.typing import ModelImageSize @@ -10,7 +9,7 @@ from ._layer import Layer if TYPE_CHECKING: - from torch import Tensor, nn + from torch import nn class ForwardLayer(Layer): @@ -19,10 +18,8 @@ class ForwardLayer(Layer): Parameters ---------- - output_size: + neuron_count: The number of neurons in this layer - input_size: - The number of neurons in the previous layer Raises ------ @@ -31,22 +28,26 @@ class ForwardLayer(Layer): If output_size < 1 """ - def __init__(self, output_size: int, input_size: int | None = None): - if input_size is not None: - self._set_input_size(input_size=input_size) - - _check_bounds("output_size", output_size, lower_bound=_ClosedBound(1)) + def __init__(self, neuron_count: int): + _check_bounds("neuron_count", neuron_count, lower_bound=_ClosedBound(1)) - self._output_size = output_size + self._input_size: int | None = None + self._output_size = neuron_count def _get_internal_layer(self, **kwargs: Any) -> nn.Module: + from ._internal_layers import _InternalForwardLayer # Slow import on global level + if "activation_function" not in kwargs: raise ValueError( "The activation_function is not set. The internal layer can only be created when the activation_function is provided in the kwargs.", ) else: activation_function: str = kwargs["activation_function"] - return _create_internal_model(self._input_size, self._output_size, activation_function) + + if self._input_size is None: + raise ValueError("The input_size is not yet set.") + + return _InternalForwardLayer(self._input_size, self._output_size, activation_function) @property def input_size(self) -> int: @@ -58,6 +59,9 @@ def input_size(self) -> int: result: The amount of values being passed into this layer. """ + if self._input_size is None: + raise ValueError("The input_size is not yet set.") + return self._input_size @property @@ -68,7 +72,7 @@ def output_size(self) -> int: Returns ------- result: - The Number of Neurons in this layer. + The number of neurons in this layer. """ return self._output_size @@ -76,30 +80,12 @@ def _set_input_size(self, input_size: int | ModelImageSize) -> None: if isinstance(input_size, ModelImageSize): raise TypeError("The input_size of a forward layer has to be of type int.") - _check_bounds("input_size", input_size, lower_bound=_ClosedBound(1)) - self._input_size = input_size def __hash__(self) -> int: - """ - Return a deterministic hash value for this forward layer. - - Returns - ------- - hash: - the hash value - """ return _structural_hash(self._input_size, self._output_size) def __eq__(self, other: object) -> bool: - """ - Compare two forward layer instances. - - Returns - ------- - equals: - 'True' if input and output size are equal, 'False' otherwise. - """ if not isinstance(other, ForwardLayer): return NotImplemented if self is other: @@ -107,41 +93,6 @@ def __eq__(self, other: object) -> bool: return self._input_size == other._input_size and self._output_size == other._output_size def __sizeof__(self) -> int: - """ - Return the complete size of this object. - - Returns - ------- - size: - Size of this object in bytes. - """ import sys return sys.getsizeof(self._input_size) + sys.getsizeof(self._output_size) - - -def _create_internal_model(input_size: int, output_size: int, activation_function: str) -> nn.Module: - from torch import nn - - _init_default_device() - - class _InternalLayer(nn.Module): - def __init__(self, input_size: int, output_size: int, activation_function: str): - super().__init__() - self._layer = nn.Linear(input_size, output_size) - match activation_function: - case "sigmoid": - self._fn = nn.Sigmoid() - case "relu": - self._fn = nn.ReLU() - case "softmax": - self._fn = nn.Softmax() - case "none": - self._fn = None - case _: - raise ValueError("Unknown Activation Function: " + activation_function) - - def forward(self, x: Tensor) -> Tensor: - return self._fn(self._layer(x)) if self._fn is not None else self._layer(x) - - return _InternalLayer(input_size, output_size, activation_function) diff --git a/src/safeds/ml/nn/layers/_internal_layers.py b/src/safeds/ml/nn/layers/_internal_layers.py new file mode 100644 index 000000000..140be6807 --- /dev/null +++ b/src/safeds/ml/nn/layers/_internal_layers.py @@ -0,0 +1,130 @@ +# These class must not be nested inside a function, since pickle cannot serialize local classes. Because of this, the +# slow import of torch must be on the global level. To still evaluate the torch import lazily, the classes are moved to +# a separate file. + +from __future__ import annotations + +from typing import Literal + +from torch import Tensor, nn # slow import + +from safeds._config import _init_default_device + + +class _InternalConvolutional2DLayer(nn.Module): + def __init__( + self, + input_size: int, + output_size: int, + kernel_size: int, + activation_function: Literal["sigmoid", "relu", "softmax"], + padding: int, + stride: int, + transpose: bool, + output_padding: int, + ): + super().__init__() + + _init_default_device() + + if transpose: + self._layer = nn.ConvTranspose2d( + in_channels=input_size, + out_channels=output_size, + kernel_size=kernel_size, + padding=padding, + stride=stride, + output_padding=output_padding, + ) + else: + self._layer = nn.Conv2d( + in_channels=input_size, + out_channels=output_size, + kernel_size=kernel_size, + padding=padding, + stride=stride, + ) + match activation_function: + case "sigmoid": + self._fn = nn.Sigmoid() + case "relu": + self._fn = nn.ReLU() + case "softmax": + self._fn = nn.Softmax() + + def forward(self, x: Tensor) -> Tensor: + return self._fn(self._layer(x)) + + +class _InternalFlattenLayer(nn.Module): + def __init__(self) -> None: + super().__init__() + + _init_default_device() + + self._layer = nn.Flatten() + + def forward(self, x: Tensor) -> Tensor: + return self._layer(x) + + +class _InternalForwardLayer(nn.Module): + def __init__(self, input_size: int, output_size: int, activation_function: str): + super().__init__() + + _init_default_device() + + self._layer = nn.Linear(input_size, output_size) + match activation_function: + case "sigmoid": + self._fn = nn.Sigmoid() + case "relu": + self._fn = nn.ReLU() + case "softmax": + self._fn = nn.Softmax() + case "none": + self._fn = None + case _: + raise ValueError("Unknown Activation Function: " + activation_function) + + def forward(self, x: Tensor) -> Tensor: + return self._fn(self._layer(x)) if self._fn is not None else self._layer(x) + + +class _InternalLSTMLayer(nn.Module): + def __init__(self, input_size: int, output_size: int, activation_function: str): + super().__init__() + + _init_default_device() + + self._layer = nn.LSTM(input_size, output_size) + match activation_function: + case "sigmoid": + self._fn = nn.Sigmoid() + case "relu": + self._fn = nn.ReLU() + case "softmax": + self._fn = nn.Softmax() + case "none": + self._fn = None + case _: + raise ValueError("Unknown Activation Function: " + activation_function) + + def forward(self, x: Tensor) -> Tensor: + return self._fn(self._layer(x)[0]) if self._fn is not None else self._layer(x)[0] + + +class _InternalPooling2DLayer(nn.Module): + def __init__(self, strategy: Literal["max", "avg"], kernel_size: int, padding: int, stride: int): + super().__init__() + + _init_default_device() + + match strategy: + case "max": + self._layer = nn.MaxPool2d(kernel_size=kernel_size, padding=padding, stride=stride) + case "avg": + self._layer = nn.AvgPool2d(kernel_size=kernel_size, padding=padding, stride=stride) + + def forward(self, x: Tensor) -> Tensor: + return self._layer(x) diff --git a/src/safeds/ml/nn/layers/_lstm_layer.py b/src/safeds/ml/nn/layers/_lstm_layer.py index 0637db453..330809474 100644 --- a/src/safeds/ml/nn/layers/_lstm_layer.py +++ b/src/safeds/ml/nn/layers/_lstm_layer.py @@ -3,7 +3,6 @@ import sys from typing import TYPE_CHECKING, Any -from safeds._config import _init_default_device from safeds._utils import _structural_hash from safeds._validation import _check_bounds, _ClosedBound from safeds.ml.nn.typing import ModelImageSize @@ -11,7 +10,7 @@ from ._layer import Layer if TYPE_CHECKING: - from torch import Tensor, nn + from torch import nn class LSTMLayer(Layer): @@ -20,10 +19,8 @@ class LSTMLayer(Layer): Parameters ---------- - output_size: + neuron_count: The number of neurons in this layer - input_size: - The number of neurons in the previous layer Raises ------ @@ -32,22 +29,26 @@ class LSTMLayer(Layer): If output_size < 1 """ - def __init__(self, output_size: int, input_size: int | None = None): - if input_size is not None: - self._set_input_size(input_size=input_size) - - _check_bounds("output_size", output_size, lower_bound=_ClosedBound(1)) + def __init__(self, neuron_count: int): + _check_bounds("neuron_count", neuron_count, lower_bound=_ClosedBound(1)) - self._output_size = output_size + self._input_size: int | None = None + self._output_size = neuron_count def _get_internal_layer(self, **kwargs: Any) -> nn.Module: + from ._internal_layers import _InternalLSTMLayer # Slow import on global level + if "activation_function" not in kwargs: raise ValueError( "The activation_function is not set. The internal layer can only be created when the activation_function is provided in the kwargs.", ) else: activation_function: str = kwargs["activation_function"] - return _create_internal_model(self._input_size, self._output_size, activation_function) + + if self._input_size is None: + raise ValueError("The input_size is not yet set.") + + return _InternalLSTMLayer(self._input_size, self._output_size, activation_function) @property def input_size(self) -> int: @@ -59,6 +60,8 @@ def input_size(self) -> int: result: The amount of values being passed into this layer. """ + if self._input_size is None: + raise ValueError("The input_size is not yet set.") return self._input_size @property @@ -69,7 +72,7 @@ def output_size(self) -> int: Returns ------- result: - The Number of Neurons in this layer. + The number of neurons in this layer. """ return self._output_size @@ -77,76 +80,18 @@ def _set_input_size(self, input_size: int | ModelImageSize) -> None: if isinstance(input_size, ModelImageSize): raise TypeError("The input_size of a forward layer has to be of type int.") - _check_bounds("input_size", input_size, lower_bound=_ClosedBound(1)) - self._input_size = input_size def __hash__(self) -> int: - """ - Return a deterministic hash value for this LSTM layer. - - Returns - ------- - hash: - the hash value - """ return _structural_hash( self._input_size, self._output_size, ) # pragma: no cover def __eq__(self, other: object) -> bool: - """ - Compare two lstm layer. - - Parameters - ---------- - other: - The lstm layer to compare to. - - Returns - ------- - equals: - Whether the two lstm layer are the same. - """ if not isinstance(other, LSTMLayer): return NotImplemented return (self is other) or (self._input_size == other._input_size and self._output_size == other._output_size) def __sizeof__(self) -> int: - """ - Return the complete size of this object. - - Returns - ------- - size: - Size of this object in bytes. - """ return sys.getsizeof(self._input_size) + sys.getsizeof(self._output_size) - - -def _create_internal_model(input_size: int, output_size: int, activation_function: str) -> nn.Module: - from torch import nn - - _init_default_device() - - class _InternalLayer(nn.Module): - def __init__(self, input_size: int, output_size: int, activation_function: str): - super().__init__() - self._layer = nn.LSTM(input_size, output_size) - match activation_function: - case "sigmoid": - self._fn = nn.Sigmoid() - case "relu": - self._fn = nn.ReLU() - case "softmax": - self._fn = nn.Softmax() - case "none": - self._fn = None - case _: - raise ValueError("Unknown Activation Function: " + activation_function) - - def forward(self, x: Tensor) -> Tensor: - return self._fn(self._layer(x)[0]) if self._fn is not None else self._layer(x)[0] - - return _InternalLayer(input_size, output_size, activation_function) diff --git a/src/safeds/ml/nn/layers/_pooling2d_layer.py b/src/safeds/ml/nn/layers/_pooling2d_layer.py index 3b49c1356..d658ed848 100644 --- a/src/safeds/ml/nn/layers/_pooling2d_layer.py +++ b/src/safeds/ml/nn/layers/_pooling2d_layer.py @@ -4,13 +4,12 @@ import sys from typing import TYPE_CHECKING, Any, Literal -from safeds._config import _init_default_device from safeds._utils import _structural_hash from ._layer import Layer if TYPE_CHECKING: - from torch import Tensor, nn + from torch import nn from safeds.ml.nn.typing import ModelImageSize @@ -40,7 +39,9 @@ def __init__(self, strategy: Literal["max", "avg"], kernel_size: int, *, stride: self._output_size: ModelImageSize | None = None def _get_internal_layer(self, **kwargs: Any) -> nn.Module: # noqa: ARG002 - return _create_internal_model(self._strategy, self._kernel_size, self._padding, self._stride) + from ._internal_layers import _InternalPooling2DLayer # Slow import on global level + + return _InternalPooling2DLayer(self._strategy, self._kernel_size, self._padding, self._stride) @property def input_size(self) -> ModelImageSize: @@ -69,7 +70,7 @@ def output_size(self) -> ModelImageSize: Returns ------- result: - The Number of Neurons in this layer. + The number of neurons in this layer. Raises ------ @@ -102,14 +103,6 @@ def _set_input_size(self, input_size: int | ModelImageSize) -> None: self._output_size = None def __hash__(self) -> int: - """ - Return a deterministic hash value for this pooling 2d layer. - - Returns - ------- - hash: - the hash value - """ return _structural_hash( self._strategy, self._kernel_size, @@ -120,19 +113,6 @@ def __hash__(self) -> int: ) def __eq__(self, other: object) -> bool: - """ - Compare two pooling 2d layer. - - Parameters - ---------- - other: - The pooling 2d layer to compare to. - - Returns - ------- - equals: - Whether the two pooling 2d layer are the same. - """ if not isinstance(other, type(self)): return NotImplemented return (self is other) or ( @@ -145,14 +125,6 @@ def __eq__(self, other: object) -> bool: ) def __sizeof__(self) -> int: - """ - Return the complete size of this object. - - Returns - ------- - size: - Size of this object in bytes. - """ return ( sys.getsizeof(self._input_size) + sys.getsizeof(self._output_size) @@ -197,23 +169,3 @@ class AveragePooling2DLayer(_Pooling2DLayer): def __init__(self, kernel_size: int, *, stride: int = -1, padding: int = 0) -> None: super().__init__("avg", kernel_size, stride=stride, padding=padding) - - -def _create_internal_model(strategy: Literal["max", "avg"], kernel_size: int, padding: int, stride: int) -> nn.Module: - from torch import nn - - _init_default_device() - - class _InternalLayer(nn.Module): - def __init__(self, strategy: Literal["max", "avg"], kernel_size: int, padding: int, stride: int): - super().__init__() - match strategy: - case "max": - self._layer = nn.MaxPool2d(kernel_size=kernel_size, padding=padding, stride=stride) - case "avg": - self._layer = nn.AvgPool2d(kernel_size=kernel_size, padding=padding, stride=stride) - - def forward(self, x: Tensor) -> Tensor: - return self._layer(x) - - return _InternalLayer(strategy, kernel_size, padding, stride) diff --git a/tests/safeds/ml/nn/converters/test_input_converter_time_series.py b/tests/safeds/ml/nn/converters/test_input_converter_time_series.py index a7c7cebbb..e618b6e66 100644 --- a/tests/safeds/ml/nn/converters/test_input_converter_time_series.py +++ b/tests/safeds/ml/nn/converters/test_input_converter_time_series.py @@ -16,7 +16,7 @@ def test_should_raise_if_is_fitted_is_set_correctly_lstm() -> None: model = NeuralNetworkRegressor( InputConversionTimeSeries(prediction_name="predicted"), - [LSTMLayer(input_size=2, output_size=1)], + [LSTMLayer(neuron_count=1)], ) ts = Table.from_dict({"target": [1, 1, 1, 1], "time": [0, 0, 0, 0], "feat": [0, 0, 0, 0]}).to_time_series_dataset( target_name="target", diff --git a/tests/safeds/ml/nn/layers/test_forward_layer.py b/tests/safeds/ml/nn/layers/test_forward_layer.py index c340e2fdf..0ecd3bd05 100644 --- a/tests/safeds/ml/nn/layers/test_forward_layer.py +++ b/tests/safeds/ml/nn/layers/test_forward_layer.py @@ -7,29 +7,17 @@ from safeds.ml.nn.layers import ForwardLayer from torch import nn - -@pytest.mark.parametrize( - "input_size", - [ - 0, - ], - ids=["input_size_out_of_bounds"], -) -def test_should_raise_if_input_size_out_of_bounds(input_size: int) -> None: - with pytest.raises(OutOfBoundsError): - ForwardLayer(output_size=1, input_size=input_size) - - -@pytest.mark.parametrize( - "input_size", - [ - 1, - 20, - ], - ids=["one", "twenty"], -) -def test_should_raise_if_input_size_doesnt_match(input_size: int) -> None: - assert ForwardLayer(output_size=1, input_size=input_size).input_size == input_size +# TODO: Should be tested on a model, not a layer, since input size gets inferred +# @pytest.mark.parametrize( +# "input_size", +# [ +# 1, +# 20, +# ], +# ids=["one", "twenty"], +# ) +# def test_should_return_input_size(input_size: int) -> None: +# assert ForwardLayer(neuron_count=1).input_size == input_size @pytest.mark.parametrize( @@ -43,13 +31,15 @@ def test_should_raise_if_input_size_doesnt_match(input_size: int) -> None: ids=["sigmoid", "relu", "softmax", "none"], ) def test_should_accept_activation_function(activation_function: str, expected_activation_function: type | None) -> None: - forward_layer = ForwardLayer(output_size=1, input_size=1)._get_internal_layer( + forward_layer = ForwardLayer(neuron_count=1) + forward_layer._input_size = 1 + internal_layer = forward_layer._get_internal_layer( activation_function=activation_function, ) assert ( - forward_layer._fn is None + internal_layer._fn is None if expected_activation_function is None - else isinstance(forward_layer._fn, expected_activation_function) + else isinstance(internal_layer._fn, expected_activation_function) ) @@ -61,11 +51,15 @@ def test_should_accept_activation_function(activation_function: str, expected_ac ids=["unknown"], ) def test_should_raise_if_unknown_activation_function_is_passed(activation_function: str) -> None: + forward_layer = ForwardLayer(neuron_count=1) + forward_layer._input_size = 1 with pytest.raises( ValueError, match=rf"Unknown Activation Function: {activation_function}", ): - ForwardLayer(output_size=1, input_size=1)._get_internal_layer(activation_function=activation_function) + forward_layer._get_internal_layer( + activation_function=activation_function, + ) @pytest.mark.parametrize( @@ -77,7 +71,7 @@ def test_should_raise_if_unknown_activation_function_is_passed(activation_functi ) def test_should_raise_if_output_size_out_of_bounds(output_size: int) -> None: with pytest.raises(OutOfBoundsError): - ForwardLayer(output_size=output_size, input_size=1) + ForwardLayer(neuron_count=output_size) @pytest.mark.parametrize( @@ -88,8 +82,8 @@ def test_should_raise_if_output_size_out_of_bounds(output_size: int) -> None: ], ids=["one", "twenty"], ) -def test_should_raise_if_output_size_doesnt_match(output_size: int) -> None: - assert ForwardLayer(output_size=output_size, input_size=1).output_size == output_size +def test_should_return_output_size(output_size: int) -> None: + assert ForwardLayer(neuron_count=output_size).output_size == output_size def test_should_raise_if_input_size_is_set_with_image_size() -> None: @@ -111,13 +105,13 @@ def test_should_raise_if_activation_function_not_set() -> None: ("layer1", "layer2", "equal"), [ ( - ForwardLayer(input_size=1, output_size=2), - ForwardLayer(input_size=1, output_size=2), + ForwardLayer(neuron_count=2), + ForwardLayer(neuron_count=2), True, ), ( - ForwardLayer(input_size=1, output_size=2), - ForwardLayer(input_size=2, output_size=1), + ForwardLayer(neuron_count=2), + ForwardLayer(neuron_count=1), False, ), ], @@ -128,14 +122,14 @@ def test_should_compare_forward_layers(layer1: ForwardLayer, layer2: ForwardLaye def test_should_assert_that_forward_layer_is_equal_to_itself() -> None: - layer = ForwardLayer(input_size=1, output_size=1) + layer = ForwardLayer(neuron_count=1) assert layer.__eq__(layer) @pytest.mark.parametrize( ("layer", "other"), [ - (ForwardLayer(input_size=1, output_size=1), None), + (ForwardLayer(neuron_count=1), None), ], ids=["ForwardLayer vs. None"], ) @@ -147,8 +141,8 @@ def test_should_return_not_implemented_if_other_is_not_forward_layer(layer: Forw ("layer1", "layer2"), [ ( - ForwardLayer(input_size=1, output_size=2), - ForwardLayer(input_size=1, output_size=2), + ForwardLayer(neuron_count=2), + ForwardLayer(neuron_count=2), ), ], ids=["equal"], @@ -161,8 +155,8 @@ def test_should_assert_that_equal_forward_layers_have_equal_hash(layer1: Forward ("layer1", "layer2"), [ ( - ForwardLayer(input_size=1, output_size=2), - ForwardLayer(input_size=2, output_size=1), + ForwardLayer(neuron_count=2), + ForwardLayer(neuron_count=1), ), ], ids=["not equal"], @@ -177,7 +171,7 @@ def test_should_assert_that_different_forward_layers_have_different_hash( @pytest.mark.parametrize( "layer", [ - ForwardLayer(input_size=1, output_size=1), + ForwardLayer(neuron_count=1), ], ids=["one"], ) diff --git a/tests/safeds/ml/nn/layers/test_lstm_layer.py b/tests/safeds/ml/nn/layers/test_lstm_layer.py index dd23d3191..8d58e5dd8 100644 --- a/tests/safeds/ml/nn/layers/test_lstm_layer.py +++ b/tests/safeds/ml/nn/layers/test_lstm_layer.py @@ -7,29 +7,17 @@ from safeds.ml.nn.layers import LSTMLayer from torch import nn - -@pytest.mark.parametrize( - "input_size", - [ - 0, - ], - ids=["input_size_out_of_bounds"], -) -def test_should_raise_if_input_size_out_of_bounds(input_size: int) -> None: - with pytest.raises(OutOfBoundsError): - LSTMLayer(output_size=1, input_size=input_size) - - -@pytest.mark.parametrize( - "input_size", - [ - 1, - 20, - ], - ids=["one", "twenty"], -) -def test_should_raise_if_input_size_doesnt_match(input_size: int) -> None: - assert LSTMLayer(output_size=1, input_size=input_size).input_size == input_size +# TODO: Should be tested on a model, not a layer, since input size gets inferred +# @pytest.mark.parametrize( +# "input_size", +# [ +# 1, +# 20, +# ], +# ids=["one", "twenty"], +# ) +# def test_should_return_input_size(input_size: int) -> None: +# assert LSTMLayer(neuron_count=1).input_size == input_size @pytest.mark.parametrize( @@ -43,13 +31,15 @@ def test_should_raise_if_input_size_doesnt_match(input_size: int) -> None: ids=["sigmoid", "relu", "softmax", "none"], ) def test_should_accept_activation_function(activation_function: str, expected_activation_function: type | None) -> None: - forward_layer = LSTMLayer(output_size=1, input_size=1)._get_internal_layer( + lstm_layer = LSTMLayer(neuron_count=1) + lstm_layer._input_size = 1 + internal_layer = lstm_layer._get_internal_layer( activation_function=activation_function, ) assert ( - forward_layer._fn is None + internal_layer._fn is None if expected_activation_function is None - else isinstance(forward_layer._fn, expected_activation_function) + else isinstance(internal_layer._fn, expected_activation_function) ) @@ -61,11 +51,15 @@ def test_should_accept_activation_function(activation_function: str, expected_ac ids=["unknown"], ) def test_should_raise_if_unknown_activation_function_is_passed(activation_function: str) -> None: + lstm_layer = LSTMLayer(neuron_count=1) + lstm_layer._input_size = 1 with pytest.raises( ValueError, match=rf"Unknown Activation Function: {activation_function}", ): - LSTMLayer(output_size=1, input_size=1)._get_internal_layer(activation_function=activation_function) + lstm_layer._get_internal_layer( + activation_function=activation_function, + ) @pytest.mark.parametrize( @@ -77,7 +71,7 @@ def test_should_raise_if_unknown_activation_function_is_passed(activation_functi ) def test_should_raise_if_output_size_out_of_bounds(output_size: int) -> None: with pytest.raises(OutOfBoundsError): - LSTMLayer(output_size=output_size, input_size=1) + LSTMLayer(neuron_count=output_size) @pytest.mark.parametrize( @@ -89,7 +83,7 @@ def test_should_raise_if_output_size_out_of_bounds(output_size: int) -> None: ids=["one", "twenty"], ) def test_should_raise_if_output_size_doesnt_match(output_size: int) -> None: - assert LSTMLayer(output_size=output_size, input_size=1).output_size == output_size + assert LSTMLayer(neuron_count=output_size).output_size == output_size def test_should_raise_if_input_size_is_set_with_image_size() -> None: @@ -111,13 +105,13 @@ def test_should_raise_if_activation_function_not_set() -> None: ("layer1", "layer2", "equal"), [ ( - LSTMLayer(input_size=1, output_size=2), - LSTMLayer(input_size=1, output_size=2), + LSTMLayer(neuron_count=2), + LSTMLayer(neuron_count=2), True, ), ( - LSTMLayer(input_size=1, output_size=2), - LSTMLayer(input_size=2, output_size=1), + LSTMLayer(neuron_count=2), + LSTMLayer(neuron_count=1), False, ), ], @@ -128,14 +122,14 @@ def test_should_compare_forward_layers(layer1: LSTMLayer, layer2: LSTMLayer, equ def test_should_assert_that_forward_layer_is_equal_to_itself() -> None: - layer = LSTMLayer(input_size=1, output_size=1) + layer = LSTMLayer(neuron_count=1) assert layer.__eq__(layer) @pytest.mark.parametrize( ("layer", "other"), [ - (LSTMLayer(input_size=1, output_size=1), None), + (LSTMLayer(neuron_count=1), None), ], ids=["ForwardLayer vs. None"], ) @@ -147,8 +141,8 @@ def test_should_return_not_implemented_if_other_is_not_forward_layer(layer: LSTM ("layer1", "layer2"), [ ( - LSTMLayer(input_size=1, output_size=2), - LSTMLayer(input_size=1, output_size=2), + LSTMLayer(neuron_count=2), + LSTMLayer(neuron_count=2), ), ], ids=["equal"], @@ -161,8 +155,8 @@ def test_should_assert_that_equal_forward_layers_have_equal_hash(layer1: LSTMLay ("layer1", "layer2"), [ ( - LSTMLayer(input_size=1, output_size=2), - LSTMLayer(input_size=2, output_size=1), + LSTMLayer(neuron_count=2), + LSTMLayer(neuron_count=1), ), ], ids=["not equal"], @@ -177,7 +171,7 @@ def test_should_assert_that_different_forward_layers_have_different_hash( @pytest.mark.parametrize( "layer", [ - LSTMLayer(input_size=1, output_size=1), + LSTMLayer(neuron_count=1), ], ids=["one"], ) diff --git a/tests/safeds/ml/nn/test_forward_workflow.py b/tests/safeds/ml/nn/test_forward_workflow.py index 66aaa1a50..3ca6ea077 100644 --- a/tests/safeds/ml/nn/test_forward_workflow.py +++ b/tests/safeds/ml/nn/test_forward_workflow.py @@ -35,7 +35,7 @@ def test_forward_model(device: Device) -> None: _, test_table = ss.fit_and_transform(test_table) model = NeuralNetworkRegressor( InputConversionTable(prediction_name="predicted"), - [ForwardLayer(input_size=1, output_size=1)], + [ForwardLayer(neuron_count=1)], ) fitted_model = model.fit(train_table.to_tabular_dataset("target"), epoch_size=1, learning_rate=0.01) diff --git a/tests/safeds/ml/nn/test_lstm_workflow.py b/tests/safeds/ml/nn/test_lstm_workflow.py index 6a13a86bc..09b506263 100644 --- a/tests/safeds/ml/nn/test_lstm_workflow.py +++ b/tests/safeds/ml/nn/test_lstm_workflow.py @@ -30,7 +30,7 @@ def test_lstm_model(device: Device) -> None: model = NeuralNetworkRegressor( InputConversionTimeSeries(prediction_name="predicted"), - [ForwardLayer(input_size=7, output_size=256), LSTMLayer(input_size=256, output_size=1)], + [ForwardLayer(neuron_count=256), LSTMLayer(neuron_count=1)], ) trained_model = model.fit( train_table.to_time_series_dataset( diff --git a/tests/safeds/ml/nn/test_model.py b/tests/safeds/ml/nn/test_model.py index d45e11f6f..5b8022a2c 100644 --- a/tests/safeds/ml/nn/test_model.py +++ b/tests/safeds/ml/nn/test_model.py @@ -1,10 +1,11 @@ +import pickle + import pytest from safeds.data.image.typing import ImageSize from safeds.data.labeled.containers import TabularDataset from safeds.data.tabular.containers import Table from safeds.exceptions import ( FeatureDataMismatchError, - InputSizeError, InvalidModelStructureError, ModelNotFittedError, OutOfBoundsError, @@ -38,22 +39,17 @@ @pytest.mark.parametrize("device", get_devices(), ids=get_devices_ids()) class TestClassificationModel: - @pytest.mark.parametrize( - "input_size", - [ - None, - ], - ) - def test_should_return_input_size(self, input_size: int, device: Device) -> None: + def test_should_return_input_size(self, device: Device) -> None: configure_test_with_device(device) - assert ( - NeuralNetworkClassifier( - InputConversionTable(), - [ForwardLayer(1, input_size)], - ).input_size - == input_size + model = NeuralNetworkClassifier( + InputConversionTable(), + [ForwardLayer(neuron_count=1)], + ).fit( + Table.from_dict({"a": [1], "b": [2]}).to_tabular_dataset("a"), ) + assert model.input_size == 1 + @pytest.mark.parametrize( "epoch_size", [ @@ -66,7 +62,7 @@ def test_should_raise_if_epoch_size_out_of_bounds(self, epoch_size: int, device: with pytest.raises(OutOfBoundsError): NeuralNetworkClassifier( InputConversionTable(), - [ForwardLayer(1, 1)], + [ForwardLayer(1)], ).fit( Table.from_dict({"a": [1], "b": [2]}).to_tabular_dataset("a"), epoch_size=epoch_size, @@ -84,7 +80,7 @@ def test_should_raise_if_batch_size_out_of_bounds(self, batch_size: int, device: with pytest.raises(OutOfBoundsError): NeuralNetworkClassifier( InputConversionTable(), - [ForwardLayer(input_size=1, output_size=1)], + [ForwardLayer(neuron_count=1)], ).fit( Table.from_dict({"a": [1], "b": [2]}).to_tabular_dataset("a"), batch_size=batch_size, @@ -94,7 +90,7 @@ def test_should_raise_if_fit_function_returns_wrong_datatype(self, device: Devic configure_test_with_device(device) fitted_model = NeuralNetworkClassifier( InputConversionTable(), - [ForwardLayer(input_size=1, output_size=8), ForwardLayer(output_size=1)], + [ForwardLayer(neuron_count=8), ForwardLayer(neuron_count=1)], ).fit( Table.from_dict({"a": [1], "b": [0]}).to_tabular_dataset("a"), ) @@ -112,7 +108,7 @@ def test_should_raise_if_predict_function_returns_wrong_datatype(self, batch_siz configure_test_with_device(device) fitted_model = NeuralNetworkClassifier( InputConversionTable(), - [ForwardLayer(input_size=1, output_size=8), ForwardLayer(output_size=1)], + [ForwardLayer(neuron_count=8), ForwardLayer(neuron_count=1)], ).fit( Table.from_dict({"a": [1, 0, 1, 0, 1, 0], "b": [0, 1, 0, 12, 3, 3]}).to_tabular_dataset("a"), batch_size=batch_size, @@ -136,14 +132,14 @@ def test_should_raise_if_predict_function_returns_wrong_datatype_for_multiclass_ configure_test_with_device(device) fitted_model = NeuralNetworkClassifier( InputConversionTable(), - [ForwardLayer(input_size=1, output_size=8), ForwardLayer(output_size=3)], + [ForwardLayer(neuron_count=8), ForwardLayer(neuron_count=3)], ).fit( Table.from_dict({"a": [0, 1, 2], "b": [0, 15, 51]}).to_tabular_dataset("a"), batch_size=batch_size, ) NeuralNetworkClassifier( InputConversionTable(), - [ForwardLayer(input_size=1, output_size=8), LSTMLayer(output_size=3)], + [ForwardLayer(neuron_count=8), LSTMLayer(neuron_count=3)], ).fit( Table.from_dict({"a": [0, 1, 2], "b": [0, 15, 51]}).to_tabular_dataset("a"), batch_size=batch_size, @@ -156,7 +152,7 @@ def test_should_raise_if_model_has_not_been_fitted(self, device: Device) -> None with pytest.raises(ModelNotFittedError, match="The model has not been fitted yet."): NeuralNetworkClassifier( InputConversionTable(), - [ForwardLayer(input_size=1, output_size=1)], + [ForwardLayer(neuron_count=1)], ).predict( Table.from_dict({"a": [1]}), ) @@ -165,11 +161,11 @@ def test_should_raise_if_is_fitted_is_set_correctly_for_binary_classification(se configure_test_with_device(device) model = NeuralNetworkClassifier( InputConversionTable(), - [ForwardLayer(input_size=1, output_size=1)], + [ForwardLayer(neuron_count=1)], ) model_2 = NeuralNetworkClassifier( InputConversionTable(), - [LSTMLayer(input_size=1, output_size=1)], + [LSTMLayer(neuron_count=1)], ) assert not model.is_fitted assert not model_2.is_fitted @@ -186,11 +182,11 @@ def test_should_raise_if_is_fitted_is_set_correctly_for_multiclass_classificatio configure_test_with_device(device) model = NeuralNetworkClassifier( InputConversionTable(), - [ForwardLayer(input_size=1, output_size=1), ForwardLayer(output_size=3)], + [ForwardLayer(neuron_count=1), ForwardLayer(neuron_count=3)], ) model_2 = NeuralNetworkClassifier( InputConversionTable(), - [ForwardLayer(input_size=1, output_size=1), LSTMLayer(output_size=3)], + [ForwardLayer(neuron_count=1), LSTMLayer(neuron_count=3)], ) assert not model.is_fitted assert not model_2.is_fitted @@ -207,7 +203,7 @@ def test_should_raise_if_test_features_mismatch(self, device: Device) -> None: configure_test_with_device(device) model = NeuralNetworkClassifier( InputConversionTable(), - [ForwardLayer(input_size=1, output_size=1), ForwardLayer(output_size=3)], + [ForwardLayer(neuron_count=1), ForwardLayer(neuron_count=3)], ) model = model.fit( Table.from_dict({"a": [1, 0, 2], "b": [0, 15, 5]}).to_tabular_dataset("a"), @@ -224,7 +220,7 @@ def test_should_raise_if_train_features_mismatch(self, device: Device) -> None: configure_test_with_device(device) model = NeuralNetworkClassifier( InputConversionTable(), - [ForwardLayer(input_size=1, output_size=1), ForwardLayer(output_size=1)], + [ForwardLayer(neuron_count=1), ForwardLayer(neuron_count=1)], ) learned_model = model.fit( Table.from_dict({"a": [0.1, 0, 0.2], "b": [0, 0.15, 0.5]}).to_tabular_dataset("b"), @@ -235,24 +231,24 @@ def test_should_raise_if_train_features_mismatch(self, device: Device) -> None: ): learned_model.fit(Table.from_dict({"k": [0.1, 0, 0.2], "l": [0, 0.15, 0.5]}).to_tabular_dataset("k")) - def test_should_raise_if_table_size_and_input_size_mismatch(self, device: Device) -> None: - configure_test_with_device(device) - model = NeuralNetworkClassifier( - InputConversionTable(), - [ForwardLayer(input_size=1, output_size=1), ForwardLayer(output_size=3)], - ) - with pytest.raises( - InputSizeError, - ): - model.fit( - Table.from_dict({"a": [1, 0, 2], "b": [0, 15, 5], "c": [3, 33, 333]}).to_tabular_dataset("a"), - ) + # def test_should_raise_if_table_size_and_input_size_mismatch(self, device: Device) -> None: + # configure_test_with_device(device) + # model = NeuralNetworkClassifier( + # InputConversionTable(), + # [ForwardLayer(neuron_count=1), ForwardLayer(neuron_count=3)], + # ) + # with pytest.raises( + # InputSizeError, + # ): + # model.fit( + # Table.from_dict({"a": [1, 0, 2], "b": [0, 15, 5], "c": [3, 33, 333]}).to_tabular_dataset("a"), + # ) def test_should_raise_if_fit_doesnt_batch_callback(self, device: Device) -> None: configure_test_with_device(device) model = NeuralNetworkClassifier( InputConversionTable(), - [ForwardLayer(input_size=1, output_size=1)], + [ForwardLayer(neuron_count=1)], ) class Test: @@ -274,7 +270,7 @@ def test_should_raise_if_fit_doesnt_epoch_callback(self, device: Device) -> None configure_test_with_device(device) model = NeuralNetworkClassifier( InputConversionTable(), - [ForwardLayer(input_size=1, output_size=1)], + [ForwardLayer(neuron_count=1)], ) class Test: @@ -448,25 +444,40 @@ def test_should_raise_if_model_has_invalid_structure( with pytest.raises(InvalidModelStructureError, match=error_msg): NeuralNetworkClassifier(input_conversion, layers) + def test_should_be_pickleable(self, device: Device) -> None: + configure_test_with_device(device) + model = NeuralNetworkClassifier( + InputConversionTable(), + [ + ForwardLayer(1), + ], + ) + fitted_model = model.fit( + Table( + { + "a": [0], + "b": [0], + }, + ).to_tabular_dataset("a"), + ) + + # Should not raise + pickle.dumps(fitted_model) + @pytest.mark.parametrize("device", get_devices(), ids=get_devices_ids()) class TestRegressionModel: - @pytest.mark.parametrize( - "input_size", - [ - None, - ], - ) - def test_should_return_input_size(self, input_size: int, device: Device) -> None: + def test_should_return_input_size(self, device: Device) -> None: configure_test_with_device(device) - assert ( - NeuralNetworkRegressor( - InputConversionTable(), - [ForwardLayer(1, input_size)], - ).input_size - == input_size + model = NeuralNetworkRegressor( + InputConversionTable(), + [ForwardLayer(neuron_count=1)], + ).fit( + Table.from_dict({"a": [1], "b": [2]}).to_tabular_dataset("a"), ) + assert model.input_size == 1 + @pytest.mark.parametrize( "epoch_size", [ @@ -479,7 +490,7 @@ def test_should_raise_if_epoch_size_out_of_bounds(self, epoch_size: int, device: with pytest.raises(OutOfBoundsError): NeuralNetworkRegressor( InputConversionTable(), - [ForwardLayer(input_size=1, output_size=1)], + [ForwardLayer(neuron_count=1)], ).fit( Table.from_dict({"a": [1], "b": [2]}).to_tabular_dataset("a"), epoch_size=epoch_size, @@ -497,7 +508,7 @@ def test_should_raise_if_batch_size_out_of_bounds(self, batch_size: int, device: with pytest.raises(OutOfBoundsError): NeuralNetworkRegressor( InputConversionTable(), - [ForwardLayer(input_size=1, output_size=1)], + [ForwardLayer(neuron_count=1)], ).fit( Table.from_dict({"a": [1], "b": [2]}).to_tabular_dataset("a"), batch_size=batch_size, @@ -515,7 +526,7 @@ def test_should_raise_if_fit_function_returns_wrong_datatype(self, batch_size: i configure_test_with_device(device) fitted_model = NeuralNetworkRegressor( InputConversionTable(), - [ForwardLayer(input_size=1, output_size=1)], + [ForwardLayer(neuron_count=1)], ).fit( Table.from_dict({"a": [1, 0, 1], "b": [2, 3, 4]}).to_tabular_dataset("a"), batch_size=batch_size, @@ -534,7 +545,7 @@ def test_should_raise_if_predict_function_returns_wrong_datatype(self, batch_siz configure_test_with_device(device) fitted_model = NeuralNetworkRegressor( InputConversionTable(), - [ForwardLayer(input_size=1, output_size=1)], + [ForwardLayer(neuron_count=1)], ).fit( Table.from_dict({"a": [1, 0, 1], "b": [2, 3, 4]}).to_tabular_dataset("a"), batch_size=batch_size, @@ -547,7 +558,7 @@ def test_should_raise_if_model_has_not_been_fitted(self, device: Device) -> None with pytest.raises(ModelNotFittedError, match="The model has not been fitted yet."): NeuralNetworkRegressor( InputConversionTable(), - [ForwardLayer(input_size=1, output_size=1)], + [ForwardLayer(neuron_count=1)], ).predict( Table.from_dict({"a": [1]}), ) @@ -556,7 +567,7 @@ def test_should_raise_if_is_fitted_is_set_correctly(self, device: Device) -> Non configure_test_with_device(device) model = NeuralNetworkRegressor( InputConversionTable(), - [ForwardLayer(input_size=1, output_size=1)], + [ForwardLayer(neuron_count=1)], ) assert not model.is_fitted model = model.fit( @@ -568,7 +579,7 @@ def test_should_raise_if_test_features_mismatch(self, device: Device) -> None: configure_test_with_device(device) model = NeuralNetworkRegressor( InputConversionTable(), - [ForwardLayer(input_size=1, output_size=1)], + [ForwardLayer(neuron_count=1)], ) model = model.fit( Table.from_dict({"a": [1, 0, 2], "b": [0, 15, 5]}).to_tabular_dataset("a"), @@ -585,7 +596,7 @@ def test_should_raise_if_train_features_mismatch(self, device: Device) -> None: configure_test_with_device(device) model = NeuralNetworkRegressor( InputConversionTable(), - [ForwardLayer(input_size=1, output_size=1)], + [ForwardLayer(neuron_count=1)], ) trained_model = model.fit( Table.from_dict({"a": [1, 0, 2], "b": [0, 15, 5]}).to_tabular_dataset("b"), @@ -598,24 +609,24 @@ def test_should_raise_if_train_features_mismatch(self, device: Device) -> None: Table.from_dict({"k": [1, 0, 2], "l": [0, 15, 5]}).to_tabular_dataset("l"), ) - def test_should_raise_if_table_size_and_input_size_mismatch(self, device: Device) -> None: - configure_test_with_device(device) - model = NeuralNetworkRegressor( - InputConversionTable(), - [ForwardLayer(input_size=1, output_size=1), ForwardLayer(output_size=3)], - ) - with pytest.raises( - InputSizeError, - ): - model.fit( - Table.from_dict({"a": [1, 0, 2], "b": [0, 15, 5], "c": [3, 33, 333]}).to_tabular_dataset("a"), - ) + # def test_should_raise_if_table_size_and_input_size_mismatch(self, device: Device) -> None: + # configure_test_with_device(device) + # model = NeuralNetworkRegressor( + # InputConversionTable(), + # [ForwardLayer(neuron_count=1), ForwardLayer(neuron_count=3)], + # ) + # with pytest.raises( + # InputSizeError, + # ): + # model.fit( + # Table.from_dict({"a": [1, 0, 2], "b": [0, 15, 5], "c": [3, 33, 333]}).to_tabular_dataset("a"), + # ) def test_should_raise_if_fit_doesnt_batch_callback(self, device: Device) -> None: configure_test_with_device(device) model = NeuralNetworkRegressor( InputConversionTable(), - [ForwardLayer(input_size=1, output_size=1)], + [ForwardLayer(neuron_count=1)], ) class Test: @@ -637,7 +648,7 @@ def test_should_raise_if_fit_doesnt_epoch_callback(self, device: Device) -> None configure_test_with_device(device) model = NeuralNetworkRegressor( InputConversionTable(), - [ForwardLayer(input_size=1, output_size=1)], + [ForwardLayer(neuron_count=1)], ) class Test: @@ -750,3 +761,23 @@ def test_should_raise_if_model_has_invalid_structure( configure_test_with_device(device) with pytest.raises(InvalidModelStructureError, match=error_msg): NeuralNetworkRegressor(input_conversion, layers) + + def test_should_be_pickleable(self, device: Device) -> None: + configure_test_with_device(device) + model = NeuralNetworkRegressor( + InputConversionTable(), + [ + ForwardLayer(1), + ], + ) + fitted_model = model.fit( + Table( + { + "a": [0], + "b": [0], + }, + ).to_tabular_dataset("a"), + ) + + # Should not raise + pickle.dumps(fitted_model)