Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions fenn/nn/models/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
4 changes: 2 additions & 2 deletions fenn/nn/models/mlp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
106 changes: 106 additions & 0 deletions fenn/nn/models/perceptron.py
Original file line number Diff line number Diff line change
@@ -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
6 changes: 1 addition & 5 deletions tests/unit/nn/test_mlp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
92 changes: 92 additions & 0 deletions tests/unit/nn/test_perceptron.py
Original file line number Diff line number Diff line change
@@ -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"