Repository: aeon-toolkit/aeon
Pull request #2537 (2025-02-12 21:17:25)

[ENH] Test coverage for MLP Network improved
<!--
Thanks for contributing a pull request! Please ensure you have taken a look at our
contribution guide: https://www.aeon-toolkit.org/en/latest/contributing.html.

Feel free to delete sections of this template if they do not apply to your PR,
avoid submitting a blank template or empty sections.
If you are a new contributor, do not delete this template without a suitable
replacement or reason. If in doubt, ask for help. We're here to help!

Please be aware that we are a team of volunteers so patience is
necessary when waiting for a review or reply. There may not be a quick turnaround for
reviews during slow periods. While we value all contributions big or small, pull
requests which do not follow our guidelines may be closed.
-->

#### Reference Issues/PRs
Contributes to #2497 
<!--
Example: Fixes #1234. See also #3456.
Please use keywords (e.g., Fixes) to create link to the issues or pull requests
you resolved, so that they will automatically be closed when your pull request
is merged. See https://github.com/blog/1506-closing-issues-via-pull-requests
-->

#### What does this implement/fix? Explain your changes.
Adds test coverage for _mlp.py
<!--
A clear and concise description of what you have implemented.
-->

#### Does your contribution introduce a new dependency? If yes, which one?
No
<!--
If your contribution does add a dependency, we may suggest adding it as an
optional/soft dependency to keep external dependencies of the core aeon package
to a minimum.
-->

#### Any other comments?
Achieves 96% coverage on _mlp.py, checked using 
```pytest --cov=aeon.networks aeon/networks/ ```
<!--
Any other information that is important to this PR or helpful for reviewers.
-->

### PR checklist

<!--
Please go through the checklist below. Please feel free to remove points if they are
not applicable. To check a box, replace the space inside the square brackets with an
'x' i.e. [x].
-->

##### For all contributions
- [ ] I've added myself to the [list of contributors](https://github.com/aeon-toolkit/aeon/blob/main/.all-contributorsrc). Alternatively, you can use the [@all-contributors](https://allcontributors.org/docs/en/bot/usage) bot to do this for you after the PR has been merged.
- [x] The PR title starts with either [ENH], [MNT], [DOC], [BUG], [REF], [DEP] or [GOV] indicating whether the PR topic is related to enhancement, maintenance, documentation, bugs, refactoring, deprecation or governance.

##### For new estimators and functions
- [ ] I've added the estimator/function to the online [API documentation](https://www.aeon-toolkit.org/en/latest/api_reference.html).
- [ ] (OPTIONAL) I've added myself as a `__maintainer__` at the top of relevant files and want to be contacted regarding its maintenance. Unmaintained files may be removed. This is for the full file, and you should not add yourself if you are just making minor changes or do not want to help maintain its contents.

##### For developers with write access
- [ ] (OPTIONAL) I've updated aeon's [CODEOWNERS](https://github.com/aeon-toolkit/aeon/blob/main/CODEOWNERS) to receive notifications about future changes to these files.


<!--
Thanks for contributing!
-->


aeon-actions-bot[bot]: 
## Thank you for contributing to `aeon`

I have added the following labels to this PR based on the title: **[ $\\color{#FEF1BE}{\\textsf{enhancement}}$ ]**.
I have added the following labels to this PR based on the changes made: **[ $\\color{#379E11}{\\textsf{networks}}$ ]**. Feel free to change these if they do not properly represent the PR.

The [Checks](https://github.com/aeon-toolkit/aeon/pull/2537/checks) tab will show the status of our automated tests. You can click on individual test runs in the tab or "Details" in the panel below to see more information if there is a failure.

If our `pre-commit` code quality check fails, any trivial fixes will automatically be pushed to your PR unless it is a draft.

Don't hesitate to ask questions on the `aeon` [Slack](https://join.slack.com/t/aeon-toolkit/shared_invite/zt-22vwvut29-HDpCu~7VBUozyfL_8j3dLA) channel if you have any.

### PR CI actions

These checkboxes will add labels to enable/disable CI functionality for this PR. This may not take effect immediately, and a new commit may be required to run the new configuration.

- [ ] Run `pre-commit` checks for all files
- [ ] Run `mypy` typecheck tests
- [ ] Run all `pytest` tests and configurations
- [ ] Run all notebook example tests
- [ ] Run numba-disabled `codecov` tests
- [ ] Stop automatic `pre-commit` fixes (always disabled for drafts)
- [ ] Disable numba cache loading
- [ ] Push an empty commit to re-run CI checks
    
shinymack: @baraline @hadifawaz1999, I've updated the tests to explicitly assert the expected layer types and verify the model parameters. Initially, isinstance(input_layer, InputLayer) wasn't working because input_layer is a KerasTensor, not a direct layer instance. To resolve this, I accessed _keras_history[0], which gives the actual layer. Let me know if this approach aligns with what you were expecting.

Also, I ensured TensorFlow is treated as a soft dependency by moving imports inside the test functions, so the CI should now pass without issues. Please review the changes and let me know if any further modifications are needed.

Thanks for your time!
baraline: Thanks for the changes, I'm not very familiar with TensorFlow, so I'll let Ali confirm about the use of `_keras_history[0]`, but about the parameters, I was thinking more like what you did here `output_layer_actual.rate == dropout_last`.

The goal is to test that we've built a the network that matches the parameter that we define in `__init__` (i.e that we don't forget to give those parameters when building the network), hence testing directly from the estimator (e.g. `mlp.dropout_last == dropout_last`) is not what I had in mind, but more like what you did for `output_layer_actual.rate == dropout_last`. 

hadifawaz1999: > Thanks for the changes, I'm not very familiar with TensorFlow, so I'll let Ali confirm about the use of `_keras_history[0]`, but about the parameters, I was thinking more like what you did here `output_layer_actual.rate == dropout_last`.
> 
> The goal is to test that we've built a the network that matches the parameter that we define in `__init__` (i.e that we don't forget to give those parameters when building the network), hence testing directly from the estimator (e.g. `mlp.dropout_last == dropout_last`) is not what I had in mind, but more like what you did for `output_layer_actual.rate == dropout_last`.

I would prefer not using ```_keras_history``` as it is unknown if it will still exists in new versions to come, so i would instead do as @baraline is proposing and check if the internal parameters of the layers are correct, and if they are none or not, also their output shpaes
shinymack: @baraline @hadifawaz1999 Thanks for the feedback! I’ve updated the tests to avoid using _keras_history and instead wrapped the returned input_layer and output_layer in a Model. This way, I can directly inspect model.layers to verify the structure and parameters, making the checks more reliable and future-proof. Let me know if this looks good or if there’s anything else you’d suggest!
shinymack: > Thanks for taking care of this, but could you explicity say "mlp" in the test function names ? this way we are sure of the test association

Updated the test function names to include 'mlp'. Let me know if anything else is needed!

Patch:
diff --git a/aeon/classification/feature_based/_catch22.py b/aeon/classification/feature_based/_catch22.py
index bfad28dd..bec5deff 100644
--- a/aeon/classification/feature_based/_catch22.py
+++ b/aeon/classification/feature_based/_catch22.py
@@ -67,6 +67,17 @@ class Catch22Classifier(BaseClassifier):
         if None a 'prefer' value of "threads" is used by default.
         Valid options are "loky", "multiprocessing", "threading" or a custom backend.
         See the joblib Parallel documentation for more details.
+    class_weight{“balanced”, “balanced_subsample”}, dict or list of dicts, default=None
+        From sklearn documentation:
+        If not given, all classes are supposed to have weight one.
+        The “balanced” mode uses the values of y to automatically adjust weights
+        inversely proportional to class frequencies in the input data as
+        n_samples / (n_classes * np.bincount(y))
+        The “balanced_subsample” mode is the same as “balanced” except that weights
+        are computed based on the bootstrap sample for every tree grown.
+        For multi-output, the weights of each column of y will be multiplied.
+        Note that these weights will be multiplied with sample_weight (passed through
+        the fit method) if sample_weight is specified.
 
     Attributes
     ----------
@@ -132,6 +143,7 @@ class Catch22Classifier(BaseClassifier):
         random_state=None,
         n_jobs=1,
         parallel_backend=None,
+        class_weight=None,
     ):
         self.features = features
         self.catch24 = catch24
@@ -142,6 +154,7 @@ class Catch22Classifier(BaseClassifier):
         self.random_state = random_state
         self.n_jobs = n_jobs
         self.parallel_backend = parallel_backend
+        self.class_weight = class_weight
 
         super().__init__()
 
@@ -175,7 +188,7 @@ class Catch22Classifier(BaseClassifier):
 
         self.estimator_ = _clone_estimator(
             (
-                RandomForestClassifier(n_estimators=200)
+                RandomForestClassifier(n_estimators=200, class_weight=self.class_weight)
                 if self.estimator is None
                 else self.estimator
             ),
diff --git a/aeon/classification/feature_based/_signature_classifier.py b/aeon/classification/feature_based/_signature_classifier.py
index 88308436..a3f659ef 100644
--- a/aeon/classification/feature_based/_signature_classifier.py
+++ b/aeon/classification/feature_based/_signature_classifier.py
@@ -61,6 +61,17 @@ class SignatureClassifier(BaseClassifier):
         Signature truncation depth.
     random_state : int, default=None
         If `int`, random_state is the seed used by the random number generator;
+    class_weight{“balanced”, “balanced_subsample”}, dict or list of dicts, default=None
+        From sklearn documentation:
+        If not given, all classes are supposed to have weight one.
+        The “balanced” mode uses the values of y to automatically adjust weights
+        inversely proportional to class frequencies in the input data as
+        n_samples / (n_classes * np.bincount(y))
+        The “balanced_subsample” mode is the same as “balanced” except that weights
+        are computed based on the bootstrap sample for every tree grown.
+        For multi-output, the weights of each column of y will be multiplied.
+        Note that these weights will be multiplied with sample_weight (passed through
+        the fit method) if sample_weight is specified.
 
     Attributes
     ----------
@@ -105,6 +116,7 @@ class SignatureClassifier(BaseClassifier):
         sig_tfm="signature",
         depth=4,
         random_state=None,
+        class_weight=None,
     ):
         self.estimator = estimator
         self.augmentation_list = augmentation_list
@@ -116,7 +128,7 @@ class SignatureClassifier(BaseClassifier):
         self.sig_tfm = sig_tfm
         self.depth = depth
         self.random_state = random_state
-
+        self.class_weight = class_weight
         super().__init__()
 
         self.signature_method = SignatureTransformer(
@@ -135,7 +147,9 @@ class SignatureClassifier(BaseClassifier):
         """Set up the full signature method pipeline."""
         # Use rf if no classifier is set
         if self.estimator is None:
-            classifier = RandomForestClassifier(random_state=self.random_state)
+            classifier = RandomForestClassifier(
+                random_state=self.random_state, class_weight=self.class_weight
+            )
         else:
             classifier = _clone_estimator(self.estimator, self.random_state)
 
diff --git a/aeon/classification/feature_based/_summary.py b/aeon/classification/feature_based/_summary.py
index a4f34ff6..b6e00563 100644
--- a/aeon/classification/feature_based/_summary.py
+++ b/aeon/classification/feature_based/_summary.py
@@ -43,6 +43,17 @@ class SummaryClassifier(BaseClassifier):
         If `RandomState` instance, random_state is the random number generator;
         If `None`, the random number generator is the `RandomState` instance used
         by `np.random`.
+    class_weight{“balanced”, “balanced_subsample”}, dict or list of dicts, default=None
+        From sklearn documentation:
+        If not given, all classes are supposed to have weight one.
+        The “balanced” mode uses the values of y to automatically adjust weights
+        inversely proportional to class frequencies in the input data as
+        n_samples / (n_classes * np.bincount(y))
+        The “balanced_subsample” mode is the same as “balanced” except that weights
+        are computed based on the bootstrap sample for every tree grown.
+        For multi-output, the weights of each column of y will be multiplied.
+        Note that these weights will be multiplied with sample_weight (passed through
+        the fit method) if sample_weight is specified.
 
     Attributes
     ----------
@@ -85,6 +96,7 @@ class SummaryClassifier(BaseClassifier):
         estimator=None,
         n_jobs=1,
         random_state=None,
+        class_weight=None,
     ):
         self.summary_stats = summary_stats
         self.estimator = estimator
@@ -92,6 +104,8 @@ class SummaryClassifier(BaseClassifier):
         self.n_jobs = n_jobs
         self.random_state = random_state
 
+        self.class_weight = class_weight
+
         super().__init__()
 
     def _fit(self, X, y):
@@ -120,7 +134,7 @@ class SummaryClassifier(BaseClassifier):
 
         self.estimator_ = _clone_estimator(
             (
-                RandomForestClassifier(n_estimators=200)
+                RandomForestClassifier(n_estimators=200, class_weight=self.class_weight)
                 if self.estimator is None
                 else self.estimator
             ),
diff --git a/aeon/classification/feature_based/_tsfresh.py b/aeon/classification/feature_based/_tsfresh.py
index 28dc2dac..00021da5 100644
--- a/aeon/classification/feature_based/_tsfresh.py
+++ b/aeon/classification/feature_based/_tsfresh.py
@@ -46,6 +46,17 @@ class TSFreshClassifier(BaseClassifier):
         If `RandomState` instance, random_state is the random number generator;
         If `None`, the random number generator is the `RandomState` instance used
         by `np.random`.
+    class_weight{“balanced”, “balanced_subsample”}, dict or list of dicts, default=None
+        From sklearn documentation:
+        If not given, all classes are supposed to have weight one.
+        The “balanced” mode uses the values of y to automatically adjust weights
+        inversely proportional to class frequencies in the input data as
+        n_samples / (n_classes * np.bincount(y))
+        The “balanced_subsample” mode is the same as “balanced” except that weights
+        are computed based on the bootstrap sample for every tree grown.
+        For multi-output, the weights of each column of y will be multiplied.
+        Note that these weights will be multiplied with sample_weight (passed through
+        the fit method) if sample_weight is specified.
 
     Attributes
     ----------
@@ -86,6 +97,7 @@ class TSFreshClassifier(BaseClassifier):
         n_jobs=1,
         chunksize=None,
         random_state=None,
+        class_weight=None,
     ):
         self.default_fc_parameters = default_fc_parameters
         self.relevant_feature_extractor = relevant_feature_extractor
@@ -99,6 +111,7 @@ class TSFreshClassifier(BaseClassifier):
         self._transformer = None
         self._return_majority_class = False
         self._majority_class = 0
+        self.class_weight = class_weight
 
         super().__init__()
 
@@ -137,7 +150,7 @@ class TSFreshClassifier(BaseClassifier):
         )
         self.estimator_ = _clone_estimator(
             (
-                RandomForestClassifier(n_estimators=200)
+                RandomForestClassifier(n_estimators=200, class_weight=self.class_weight)
                 if self.estimator is None
                 else self.estimator
             ),


Test patch:
diff --git a/aeon/classification/feature_based/tests/test_catch22.py b/aeon/classification/feature_based/tests/test_catch22.py
index 5a709fe4..c8067ee5 100644
--- a/aeon/classification/feature_based/tests/test_catch22.py
+++ b/aeon/classification/feature_based/tests/test_catch22.py
@@ -1,6 +1,7 @@
 """Test catch 22 classifier."""
 
 import numpy as np
+import pytest
 from sklearn.ensemble import RandomForestClassifier
 from sklearn.linear_model import RidgeClassifier
 
@@ -19,3 +20,21 @@ def test_catch22():
     c22.fit(X, y)
     p = c22.predict_proba(X)
     assert np.all(np.isin(p, [0, 1]))
+
+
+@pytest.mark.parametrize("class_weight", ["balanced", "balanced_subsample"])
+def test_catch22_classifier_with_class_weight(class_weight):
+    """Test catch22 classifier with class weight."""
+    X, y = make_example_3d_numpy(
+        n_cases=10, n_channels=1, n_timepoints=12, return_y=True, random_state=0
+    )
+    clf = Catch22Classifier(
+        estimator=RandomForestClassifier(n_estimators=5),
+        outlier_norm=True,
+        random_state=0,
+        class_weight=class_weight,
+    )
+    clf.fit(X, y)
+    predictions = clf.predict(X)
+    assert len(predictions) == len(y)
+    assert set(predictions).issubset(set(y))
diff --git a/aeon/classification/feature_based/tests/test_signature.py b/aeon/classification/feature_based/tests/test_signature.py
index b5c29df2..2d3d2972 100644
--- a/aeon/classification/feature_based/tests/test_signature.py
+++ b/aeon/classification/feature_based/tests/test_signature.py
@@ -18,3 +18,24 @@ def test_signature_classifier():
     cls = SignatureClassifier(estimator=None)
     cls._fit(X, y)
     assert isinstance(cls.pipeline.named_steps["classifier"], RandomForestClassifier)
+
+
+@pytest.mark.skipif(
+    not _check_soft_dependencies("esig", severity="none"),
+    reason="skip test if required soft dependency esig not available",
+)
+@pytest.mark.parametrize("class_weight", ["balanced", "balanced_subsample"])
+def test_signature_classifier_with_class_weight(class_weight):
+    """Test signature classifier with class weight."""
+    X, y = make_example_3d_numpy(
+        n_cases=10, n_channels=1, n_timepoints=12, return_y=True, random_state=0
+    )
+    clf = SignatureClassifier(
+        estimator=RandomForestClassifier(n_estimators=5),
+        random_state=0,
+        class_weight=class_weight,
+    )
+    clf.fit(X, y)
+    predictions = clf.predict(X)
+    assert len(predictions) == len(y)
+    assert set(predictions).issubset(set(y))
diff --git a/aeon/classification/feature_based/tests/test_summary.py b/aeon/classification/feature_based/tests/test_summary.py
index de698e61..0f53130c 100644
--- a/aeon/classification/feature_based/tests/test_summary.py
+++ b/aeon/classification/feature_based/tests/test_summary.py
@@ -1,6 +1,7 @@
 """Test summary classifier."""
 
 import numpy as np
+import pytest
 from sklearn.ensemble import RandomForestClassifier
 from sklearn.linear_model import RidgeClassifier
 
@@ -19,3 +20,20 @@ def test_summary_classifier():
     cls.fit(X, y)
     p = cls.predict_proba(X)
     assert np.all(np.isin(p, [0, 1]))
+
+
+@pytest.mark.parametrize("class_weight", ["balanced", "balanced_subsample"])
+def test_summary_classifier_with_class_weight(class_weight):
+    """Test summary classifier with class weight."""
+    X, y = make_example_3d_numpy(
+        n_cases=10, n_channels=1, n_timepoints=12, return_y=True, random_state=0
+    )
+    clf = SummaryClassifier(
+        estimator=RandomForestClassifier(n_estimators=5),
+        random_state=0,
+        class_weight=class_weight,
+    )
+    clf.fit(X, y)
+    predictions = clf.predict(X)
+    assert len(predictions) == len(y)
+    assert set(predictions).issubset(set(y))
diff --git a/aeon/classification/feature_based/tests/test_tsfresh.py b/aeon/classification/feature_based/tests/test_tsfresh.py
index 3ab965e6..92583e66 100644
--- a/aeon/classification/feature_based/tests/test_tsfresh.py
+++ b/aeon/classification/feature_based/tests/test_tsfresh.py
@@ -37,3 +37,24 @@ def test_tsfresh_classifier():
         assert cls._majority_class in [0, 1]
     cls.verbose = 1
     cls.fit(X, y)
+
+
+@pytest.mark.skipif(
+    not _check_soft_dependencies("tsfresh", severity="none"),
+    reason="skip test if required soft dependency tsfresh not available",
+)
+@pytest.mark.parametrize("class_weight", ["balanced", "balanced_subsample"])
+def test_tsfresh_classifier_with_class_weight(class_weight):
+    """Test tsfresh classifier with class weight."""
+    X, y = make_example_3d_numpy(
+        n_cases=10, n_channels=1, n_timepoints=12, return_y=True, random_state=0
+    )
+    clf = TSFreshClassifier(
+        estimator=RandomForestClassifier(n_estimators=5),
+        random_state=0,
+        class_weight=class_weight,
+    )
+    clf.fit(X, y)
+    predictions = clf.predict(X)
+    assert len(predictions) == len(y)
+    assert set(predictions).issubset(set(y))
diff --git a/aeon/networks/tests/test_mlp.py b/aeon/networks/tests/test_mlp.py
new file mode 100644
index 00000000..421a4f28
--- /dev/null
+++ b/aeon/networks/tests/test_mlp.py
@@ -0,0 +1,179 @@
+"""Tests for the MLPNetwork Model."""
+
+import pytest
+
+from aeon.networks import MLPNetwork
+from aeon.utils.validation._dependencies import _check_soft_dependencies
+
+
+@pytest.mark.skipif(
+    not _check_soft_dependencies(["tensorflow"], severity="none"),
+    reason="Tensorflow soft dependency unavailable.",
+)
+@pytest.mark.parametrize(
+    "n_layers, n_units, activation",
+    [
+        (3, 500, "relu"),
+        (5, [256, 128, 128, 64, 32], "sigmoid"),
+        (2, 128, ["tanh", "relu"]),
+    ],
+)
+def test_mlp_initialization(n_layers, n_units, activation):
+    """Test whether MLPNetwork initializes correctly with different configurations."""
+    from tensorflow.keras.layers import Dense, Dropout, Flatten, InputLayer
+    from tensorflow.keras.models import Model
+
+    mlp = MLPNetwork(n_layers=n_layers, n_units=n_units, activation=activation)
+    input_layer, output_layer = mlp.build_network((1000, 5))
+
+    # Wrap in a Model to access internal layers
+    model = Model(inputs=input_layer, outputs=output_layer)
+    layers = model.layers
+
+    assert isinstance(layers[0], InputLayer), "Expected first layer to be InputLayer"
+
+    assert isinstance(layers[1], Flatten), "Expected second layer to be Flatten"
+
+    # Check dropout and dense layers ordering
+    for i in range(n_layers):
+        dropout_layer = layers[2 + 2 * i]  # Dropout before Dense
+        dense_layer = layers[3 + 2 * i]  # Dense comes after Dropout
+
+        assert isinstance(
+            dropout_layer, Dropout
+        ), f"Expected Dropout at index {2 + 2 * i}"
+        assert isinstance(dense_layer, Dense), f"Expected Dense at index {3 + 2 * i}"
+
+        # Assert activation function
+        expected_activation = (
+            activation[i] if isinstance(activation, list) else activation
+        )
+        assert dense_layer.activation.__name__ == expected_activation, (
+            f"Expected activation {expected_activation}, "
+            f"got {dense_layer.activation.__name__}"
+        )
+
+        # Assert number of units
+        expected_units = n_units[i] if isinstance(n_units, list) else n_units
+        assert (
+            dense_layer.units == expected_units
+        ), f"Expected {expected_units} units, got {dense_layer.units}"
+
+    # Check last layer is Dropout
+    assert isinstance(layers[-1], Dropout), "Expected final layer to be Dropout"
+
+    # Assert model parameters (Just for show)
+    assert mlp.n_layers == n_layers
+    assert mlp.n_units == n_units
+    assert mlp.activation == activation
+
+
+@pytest.mark.skipif(
+    not _check_soft_dependencies(["tensorflow"], severity="none"),
+    reason="Tensorflow soft dependency unavailable.",
+)
+@pytest.mark.parametrize(
+    "dropout_rate, n_layers",
+    [
+        (0.2, 3),
+        ([0.1, 0.2, 0.3], 3),
+        pytest.param([0.1, 0.2], 3, marks=pytest.mark.xfail(raises=AssertionError)),
+    ],
+)
+def test_mlp_dropout_rate(dropout_rate, n_layers):
+    """Test MLPNetwork dropout_rate configurations."""
+    from tensorflow.keras.layers import Dense, Dropout, Flatten, InputLayer
+    from tensorflow.keras.models import Model
+
+    mlp = MLPNetwork(n_layers=n_layers, dropout_rate=dropout_rate)
+    input_layer, output_layer = mlp.build_network((1000, 5))
+
+    # Wrap in a Model to access internal layers
+    model = Model(inputs=input_layer, outputs=output_layer)
+    layers = model.layers
+
+    # Check first two layers
+    assert isinstance(layers[0], InputLayer), "Expected first layer to be InputLayer"
+    assert isinstance(layers[1], Flatten), "Expected second layer to be Flatten"
+
+    # Check dropout and dense layers ordering
+    for i in range(n_layers):
+        dropout_layer = layers[2 + 2 * i]
+        dense_layer = layers[3 + 2 * i]
+
+        assert isinstance(
+            dropout_layer, Dropout
+        ), f"Expected Dropout at index {2 + 2 * i}"
+        assert isinstance(dense_layer, Dense), f"Expected Dense at index {3 + 2 * i}"
+
+        # Assert dropout rates match expected values
+        expected_dropout = (
+            dropout_rate[i] if isinstance(dropout_rate, list) else dropout_rate
+        )
+        assert (
+            dropout_layer.rate == expected_dropout
+        ), f"Expected {expected_dropout},got {dropout_layer.rate}"
+    assert isinstance(layers[-1], Dropout), "Expected final layer to be Dropout"
+
+
+@pytest.mark.skipif(
+    not _check_soft_dependencies(["tensorflow"], severity="none"),
+    reason="Tensorflow soft dependency unavailable.",
+)
+@pytest.mark.parametrize(
+    "dropout_last",
+    [0.3, 0.5, pytest.param(1.2, marks=pytest.mark.xfail(raises=AssertionError))],
+)
+def test_mlp_dropout_last(dropout_last):
+    """Test MLPNetwork dropout_last configurations."""
+    from tensorflow.keras.layers import Dropout, Flatten, InputLayer
+    from tensorflow.keras.models import Model
+
+    mlp = MLPNetwork(dropout_last=dropout_last)
+    input_layer, output_layer = mlp.build_network((1000, 5))
+
+    # Wrap in a Model to access internal layers
+    model = Model(inputs=input_layer, outputs=output_layer)
+    layers = model.layers
+
+    assert isinstance(layers[0], InputLayer), "Expected first layer to be InputLayer"
+    assert isinstance(layers[1], Flatten), "Expected second layer to be Flatten"
+    assert isinstance(layers[-1], Dropout), "Expected final layer to be Dropout"
+
+    assert (
+        layers[-1].rate == dropout_last
+    ), f"Expected {dropout_last}, got {layers[-1].rate}"
+
+
+@pytest.mark.skipif(
+    not _check_soft_dependencies(["tensorflow"], severity="none"),
+    reason="Tensorflow soft dependency unavailable.",
+)
+@pytest.mark.parametrize("use_bias", [True, False])
+def test_mlp_use_bias(use_bias):
+    """Test MLPNetwork use_bias configurations."""
+    from tensorflow.keras.layers import Dense, Dropout, Flatten, InputLayer
+    from tensorflow.keras.models import Model
+
+    mlp = MLPNetwork(use_bias=use_bias)
+    input_layer, output_layer = mlp.build_network((1000, 5))
+
+    # Wrap in a Model to access internal layers
+    model = Model(inputs=input_layer, outputs=output_layer)
+    layers = model.layers
+
+    assert isinstance(layers[0], InputLayer), "Expected first layer to be InputLayer"
+    assert isinstance(layers[1], Flatten), "Expected second layer to be Flatten"
+    assert isinstance(layers[-1], Dropout), "Expected final layer to be Dropout"
+
+    # Find the last Dense layer before the final Dropout layer
+    last_dense_layer = next(
+        (layer for layer in reversed(layers) if isinstance(layer, Dense)), None
+    )
+
+    assert last_dense_layer is not None, "No Dense layer found before final Dropout"
+    assert isinstance(last_dense_layer, Dense), "Expected last layer to be Dense"
+
+    assert (
+        last_dense_layer.use_bias == use_bias
+    ), f"Expected use_bias {use_bias}, got {last_dense_layer.use_bias}"