From 80c1a0c1c13c3bf0e7afde637c3c7cc29bda031f Mon Sep 17 00:00:00 2001 From: ChickenisLegit Date: Sat, 8 Aug 2026 12:25:54 +0530 Subject: [PATCH] feat(nn): add SingleLayerPerceptron models Adds SingleLayerPerceptronClassifier and SingleLayerPerceptronRegressor which are essentially MLPs with zero hidden layers. Tests are included. Resolves #220 --- fenn/nn/models/__init__.py | 4 ++ fenn/nn/models/mlp.py | 4 +- fenn/nn/models/perceptron.py | 106 +++++++++++++++++++++++++++++++ tests/unit/nn/test_mlp.py | 6 +- tests/unit/nn/test_perceptron.py | 92 +++++++++++++++++++++++++++ 5 files changed, 205 insertions(+), 7 deletions(-) create mode 100644 fenn/nn/models/perceptron.py create mode 100644 tests/unit/nn/test_perceptron.py diff --git a/fenn/nn/models/__init__.py b/fenn/nn/models/__init__.py index cddc073..21c8436 100644 --- a/fenn/nn/models/__init__.py +++ b/fenn/nn/models/__init__.py @@ -1,9 +1,13 @@ from .lstm import LSTMClassifier, LSTMGenerator from .mlp import MLPClassifier, MLPRegressor +from .perceptron import SingleLayerPerceptron, SingleLayerPerceptronClassifier, SingleLayerPerceptronRegressor __all__ = [ "LSTMClassifier", "LSTMGenerator", "MLPClassifier", "MLPRegressor", + "SingleLayerPerceptron", + "SingleLayerPerceptronClassifier", + "SingleLayerPerceptronRegressor", ] diff --git a/fenn/nn/models/mlp.py b/fenn/nn/models/mlp.py index 99a71bf..4961e84 100644 --- a/fenn/nn/models/mlp.py +++ b/fenn/nn/models/mlp.py @@ -145,8 +145,8 @@ def __init__( ) if not (0.0 < validation_fraction < 1.0): raise ValueError("validation_fraction must be between 0 and 1.") - if len(hidden_layer_sizes) == 0: - raise ValueError("hidden_layer_sizes must contain at least one layer.") + if not isinstance(hidden_layer_sizes, (list, tuple)): + raise TypeError("hidden_layer_sizes must be a sequence.") self.hidden_layer_sizes = tuple(hidden_layer_sizes) self.activation = activation diff --git a/fenn/nn/models/perceptron.py b/fenn/nn/models/perceptron.py new file mode 100644 index 0000000..a756f3b --- /dev/null +++ b/fenn/nn/models/perceptron.py @@ -0,0 +1,106 @@ +"""Single-Layer Perceptron models. + +This module provides :class:`SingleLayerPerceptronClassifier` and +:class:`SingleLayerPerceptronRegressor`, which are essentially Multi-Layer +Perceptrons with zero hidden layers, connecting inputs directly to outputs. +""" + +from __future__ import annotations + +from fenn.nn.models.mlp import MLPClassifier, MLPRegressor + + +class SingleLayerPerceptronClassifier(MLPClassifier): + """Single-layer Perceptron classifier. + + A scikit-learn-style estimator for a single-layer neural network + classification (equivalent to logistic regression for binary classification, + or multinomial logistic regression for multi-class). + + Args: + solver: Optimizer used to train the weights. One of ``'adam'``, ``'sgd'``. + learning_rate_init: Initial learning rate used by the optimizer. + batch_size: Size of minibatches used during training. + max_iter: Maximum number of training epochs. + early_stopping: Whether to hold out ``validation_fraction`` of the + training data and stop training when validation loss stops + improving for ``n_iter_no_change`` epochs. + n_iter_no_change: Number of epochs with no improvement to wait + before stopping, when ``early_stopping=True``. + validation_fraction: Proportion of training data to set aside for + early stopping validation, when ``early_stopping=True``. + device: Device to train on, e.g. ``'cpu'``, ``'cuda'``, ``'mps'``. + """ + + def __init__( + self, + solver: str = "adam", + learning_rate_init: float = 0.001, + batch_size: int = 32, + max_iter: int = 200, + early_stopping: bool = False, + n_iter_no_change: int = 10, + validation_fraction: float = 0.1, + device: str = "cpu", + ): + super().__init__( + hidden_layer_sizes=(), + activation="identity", + solver=solver, + learning_rate_init=learning_rate_init, + batch_size=batch_size, + max_iter=max_iter, + early_stopping=early_stopping, + n_iter_no_change=n_iter_no_change, + validation_fraction=validation_fraction, + device=device, + ) + + +class SingleLayerPerceptronRegressor(MLPRegressor): + """Single-layer Perceptron regressor. + + A scikit-learn-style estimator for a single-layer neural network + regression (equivalent to linear regression). + + Args: + solver: Optimizer used to train the weights. One of ``'adam'``, ``'sgd'``. + learning_rate_init: Initial learning rate used by the optimizer. + batch_size: Size of minibatches used during training. + max_iter: Maximum number of training epochs. + early_stopping: Whether to hold out ``validation_fraction`` of the + training data and stop training when validation loss stops + improving for ``n_iter_no_change`` epochs. + n_iter_no_change: Number of epochs with no improvement to wait + before stopping, when ``early_stopping=True``. + validation_fraction: Proportion of training data to set aside for + early stopping validation, when ``early_stopping=True``. + device: Device to train on, e.g. ``'cpu'``, ``'cuda'``, ``'mps'``. + """ + + def __init__( + self, + solver: str = "adam", + learning_rate_init: float = 0.001, + batch_size: int = 32, + max_iter: int = 200, + early_stopping: bool = False, + n_iter_no_change: int = 10, + validation_fraction: float = 0.1, + device: str = "cpu", + ): + super().__init__( + hidden_layer_sizes=(), + activation="identity", + solver=solver, + learning_rate_init=learning_rate_init, + batch_size=batch_size, + max_iter=max_iter, + early_stopping=early_stopping, + n_iter_no_change=n_iter_no_change, + validation_fraction=validation_fraction, + device=device, + ) + +# Alias for backwards compatibility with users who might just expect SingleLayerPerceptron +SingleLayerPerceptron = SingleLayerPerceptronClassifier diff --git a/tests/unit/nn/test_mlp.py b/tests/unit/nn/test_mlp.py index 29aaa11..b0bbf7f 100644 --- a/tests/unit/nn/test_mlp.py +++ b/tests/unit/nn/test_mlp.py @@ -100,11 +100,7 @@ def test_invalid_validation_fraction_raises(self): ): MLPRegressor(validation_fraction=1.5) - def test_empty_hidden_layer_sizes_raises(self): - with pytest.raises( - ValueError, match="hidden_layer_sizes must contain at least one" - ): - MLPClassifier(hidden_layer_sizes=()) + def test_predict_before_fit_raises(self): with pytest.raises(RuntimeError, match="not fitted yet"): diff --git a/tests/unit/nn/test_perceptron.py b/tests/unit/nn/test_perceptron.py new file mode 100644 index 0000000..511649d --- /dev/null +++ b/tests/unit/nn/test_perceptron.py @@ -0,0 +1,92 @@ +"""Tests for fenn/nn/models/perceptron.py""" + +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest +import torch + +from fenn.nn.models.perceptron import SingleLayerPerceptronClassifier, SingleLayerPerceptronRegressor + + +# ── Fixtures ─────────────────────────────────────────────────────────────────── + +@pytest.fixture(autouse=True) +def _mock_rich_progress(): + def _fake_progress(*args, **kwargs): + mock_progress = MagicMock() + mock_progress.add_task.return_value = MagicMock() + return mock_progress + + with patch( + "fenn.nn.trainers.classification_trainer.Progress", side_effect=_fake_progress + ): + with patch( + "fenn.nn.trainers.regression_trainer.Progress", side_effect=_fake_progress + ): + yield + + +# ── Helpers ──────────────────────────────────────────────────────────────────── + +def _make_classification_data(n_samples=40, n_features=4, n_classes=2, seed=0): + rng = np.random.default_rng(seed) + X = rng.normal(size=(n_samples, n_features)) + weights = rng.normal(size=(n_features,)) + scores = X @ weights + if n_classes == 2: + y = (scores > np.median(scores)).astype(int) + else: + thresholds = np.quantile(scores, np.linspace(0, 1, n_classes + 1)[1:-1]) + y = np.digitize(scores, thresholds) + return X, y + + +def _make_regression_data(n_samples=40, n_features=4, seed=0): + rng = np.random.default_rng(seed) + X = rng.normal(size=(n_samples, n_features)) + weights = rng.normal(size=(n_features,)) + y = X @ weights + 0.01 * rng.normal(size=(n_samples,)) + return X, y + + +# ── Tests ────────────────────────────────────────────────────────────────────── + +def test_slp_classifier_init(): + clf = SingleLayerPerceptronClassifier(learning_rate_init=0.01, max_iter=10) + assert clf.hidden_layer_sizes == () + assert clf.activation == "identity" + assert clf.learning_rate_init == 0.01 + + +def test_slp_regressor_init(): + reg = SingleLayerPerceptronRegressor(learning_rate_init=0.01, max_iter=10) + assert reg.hidden_layer_sizes == () + assert reg.activation == "identity" + assert reg.learning_rate_init == 0.01 + + +def test_slp_classifier_fit_predict_binary(): + X, y = _make_classification_data(n_classes=2) + clf = SingleLayerPerceptronClassifier(max_iter=5) + clf.fit(X, y) + + assert clf.n_features_in_ == X.shape[1] + + # Check predictions + y_pred = clf.predict(X) + assert y_pred.shape == y.shape + assert set(np.unique(y_pred)).issubset({0, 1}) + + +def test_slp_regressor_fit_predict(): + X, y = _make_regression_data() + reg = SingleLayerPerceptronRegressor(max_iter=5) + reg.fit(X, y) + + assert reg.n_features_in_ == X.shape[1] + + # Check predictions + y_pred = reg.predict(X) + assert y_pred.shape == y.shape + assert y_pred.dtype.kind == "f"