diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..ab4f9a7 --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,33 @@ +name: Run Tests + +on: + push: + branches: + - develop + - main + pull_request: + branches: + - develop + - main + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + pipx install poetry + poetry install + + - name: Run tests + run: | + poetry run pytest diff --git a/CHANGELOG.md b/CHANGELOG.md index 57b4ad0..045ad9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## v0.1.4 (05/14/2026) + +- Added a copy method to Sequential class to make deep copies of a model instance. +- Added tests for Sequential class and builder, and added a test runner to CI/CD. + ## v0.1.3 (05/07/2026) - Removed datasets and pillow as package dependencies and moved to dev dependencies diff --git a/poetry.lock b/poetry.lock index 101a70a..1857e1a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1175,6 +1175,18 @@ perf = ["ipython"] test = ["packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + [[package]] name = "ipykernel" version = "7.2.0" @@ -2213,6 +2225,22 @@ files = [ {file = "platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934"}, ] +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + [[package]] name = "prompt-toolkit" version = "3.0.52" @@ -2511,6 +2539,28 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pytest" +version = "9.0.3" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"}, + {file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1.0.1" +packaging = ">=22" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -3713,4 +3763,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "115951908beade22e222cf3a4529a286eb0af4f98ea7d935e55eda6d95942c23" +content-hash = "fd75ddddb6185c66ed07bacf8fbe5069fab3a5166012ca75bd728a11d21a476f" diff --git a/pyproject.toml b/pyproject.toml index ecf339a..c92779e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "phitodeep" -version = "0.1.3" +version = "0.1.4" description = "Deep learning framework built from scratch with numpy!" authors = ["Ralph Dugue"] license = "Apache License 2.0" @@ -23,5 +23,6 @@ dev = [ "sphinx-autoapi (>=3.8.0,<4.0.0)", "sphinx-rtd-theme (>=3.1.0,<4.0.0)", "pillow (>=12.2.0,<13.0.0)", - "datasets (>=4.8.4,<5.0.0)" + "datasets (>=4.8.4,<5.0.0)", + "pytest (>=9.0.3,<10.0.0)" ] diff --git a/src/phitodeep/layers/activation.py b/src/phitodeep/layers/activation.py index 0c9e272..40f26f8 100644 --- a/src/phitodeep/layers/activation.py +++ b/src/phitodeep/layers/activation.py @@ -20,6 +20,11 @@ def backward(self, dL_dZ): dL_dX = dL_dZ * (X > 0).astype(float) return dL_dX + def copy(self): + new_layer = ReLu() + new_layer.cache = self.cache.copy() + return new_layer + class Sigmoid(Layer): def __init__(self) -> None: @@ -39,6 +44,11 @@ def backward(self, dL_dZ): dL_dX = dL_dZ * Z * (1 - Z) return dL_dX + def copy(self): + new_layer = Sigmoid() + new_layer.cache = self.cache.copy() + return new_layer + class Tanh(Layer): def __init__(self) -> None: @@ -60,6 +70,11 @@ def backward(self, dL_dZ): dL_dX = dL_dZ * (1 - Z**2) return dL_dX + def copy(self): + new_layer = Tanh() + new_layer.cache = self.cache.copy() + return new_layer + class Softmax(Layer): def __init__(self) -> None: @@ -85,6 +100,11 @@ def backward(self, dL_dZ): """ return dL_dZ + def copy(self): + new_layer = Softmax() + new_layer.cache = self.cache.copy() + return new_layer + class ELU(Layer): def __init__(self, alpha=1.0) -> None: @@ -104,3 +124,8 @@ def backward(self, dL_dZ): X = self.cache["X"] dL_dX = dL_dZ * np.where(X > 0, 1.0, self.alpha_activation * np.exp(X)) return dL_dX + + def copy(self): + new_layer = ELU(self.alpha_activation) + new_layer.cache = self.cache.copy() + return new_layer diff --git a/src/phitodeep/layers/base.py b/src/phitodeep/layers/base.py index 9146307..7c8c958 100644 --- a/src/phitodeep/layers/base.py +++ b/src/phitodeep/layers/base.py @@ -2,6 +2,10 @@ class Layer: + """ + Base class for all layers in the network. + """ + def __init__(self, name) -> None: self.name = name self.cache = {} @@ -22,8 +26,15 @@ def backward(self, dL_dZ): """ raise NotImplementedError(f"Block '{self.name}' must implement backward method") + def copy(self): + raise NotImplementedError(f"Block '{self.name}' must implement copy method") + class Flatten(Layer): + """ + Flattens the input tensor into a 2D tensor. + """ + def __init__(self): super().__init__("flatten") @@ -41,8 +52,17 @@ def backward(self, dL_dZ): X = self.cache["X"] return dL_dZ.reshape(X.shape) + def copy(self): + new_layer = Flatten() + new_layer.cache = self.cache.copy() + return new_layer + class Dense(Layer): + """ + Fully connected layer. + """ + def __init__(self, input_size, output_size): super().__init__("dense") self.grads = {} @@ -83,3 +103,11 @@ def backward(self, dL_dZ): dL_dX = np.dot(dL_dZ, self.W.T) return dL_dX + + def copy(self): + new_layer = Dense(self.input_size, self.output_size) + new_layer.W = self.W.copy() + new_layer.b = self.b.copy() + new_layer.grads = {k: v.copy() for k, v in self.grads.items()} + new_layer.cache = {k: v.copy() for k, v in self.cache.items()} + return new_layer diff --git a/src/phitodeep/model.py b/src/phitodeep/model.py index 2bce15c..80f6e09 100644 --- a/src/phitodeep/model.py +++ b/src/phitodeep/model.py @@ -46,6 +46,18 @@ def setloss(self, loss_class): self.loss_class = loss_class def train(self, X, y, X_test, y_test): + """ + Train the model using the specified optimizer and loss function. + + Args: + X (np.ndarray): Training data. + y (np.ndarray): Training labels. + X_test (np.ndarray): Test data. + y_test (np.ndarray): Test labels. + + Returns: + list: A list of tuples containing the training and test losses for each epoch. + """ match self.optimizer: case "sgd": optimizer = optimization.SGD(alpha=self.alpha) @@ -68,9 +80,15 @@ def train(self, X, y, X_test, y_test): print("Training complete.") print("-" * 60) - print(f"Starting Training Loss: {losses[0][0]:.4f} | Starting Test Loss: {losses[0][1]:.4f}") - print(f"Final Training Loss: {losses[-1][0]:.4f} | Final Test Loss: {losses[-1][1]:.4f}") - print(f"Training Loss Improvement: {losses[0][0] - losses[-1][0]:.4f} | Test Loss Improvement: {losses[0][1] - losses[-1][1]:.4f}") + print( + f"Starting Training Loss: {losses[0][0]:.4f} | Starting Test Loss: {losses[0][1]:.4f}" + ) + print( + f"Final Training Loss: {losses[-1][0]:.4f} | Final Test Loss: {losses[-1][1]:.4f}" + ) + print( + f"Training Loss Improvement: {losses[0][0] - losses[-1][0]:.4f} | Test Loss Improvement: {losses[0][1] - losses[-1][1]:.4f}" + ) print("-" * 60) return losses @@ -129,6 +147,17 @@ def summary(self): print(f"Layer {i}: {layer.name.upper():<10}") print("-" * 60) + def copy(self): + """Return a copy of the model.""" + return Sequential( + *[layer.copy() for layer in self.layers], + alpha=self.alpha, + optimizer=self.optimizer, + batch_size=self.batch_size, + epochs=self.epochs, + loss_class=self.loss_class, + ) + class SequentialBuilder: """Fluent API for building Sequential models.""" @@ -204,7 +233,7 @@ def loss(self, loss_class): def build(self): """Build and return the Sequential model.""" return Sequential( - *self.layers, + *[layer.copy() for layer in self.layers], alpha=self.alpha_value, optimizer=self.optimizer_name, batch_size=self.batch_size, diff --git a/src/phitodeep/optimization.py b/src/phitodeep/optimization.py index 55d3989..cabfadb 100644 --- a/src/phitodeep/optimization.py +++ b/src/phitodeep/optimization.py @@ -51,11 +51,13 @@ def step(self, layers): def train_loop( model, X, y, X_test, y_test, loss_class, optimizer, epochs=1000, batch_size=1 ): + losses = [] + rng = np.random.default_rng() for epoch in range(epochs): for _ in range(len(X) // batch_size): - indices = np.random.randint(0, len(X), batch_size) + indices = rng.integers(0, len(X), batch_size) X_batch = X[indices] y_batch = y[indices] diff --git a/tests/test_model.py b/tests/test_model.py new file mode 100644 index 0000000..80a4d6b --- /dev/null +++ b/tests/test_model.py @@ -0,0 +1,407 @@ +import numpy as np +import pytest + +from phitodeep import loss as ls +from phitodeep import model as m +from phitodeep.layers import activation as a +from phitodeep.layers import base as b + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def simple_model(): + """Minimal 3-layer model with no training needed.""" + return m.Sequential( + b.Dense(4, 8), + a.ReLu(), + b.Dense(8, 2), + epochs=2, + batch_size=4, + ) + + +@pytest.fixture +def small_data(): + """16-sample, 4-feature classification dataset with fixed seed.""" + rng = np.random.default_rng(42) + X = rng.standard_normal((16, 4)).astype(np.float32) + y = rng.integers(0, 2, 16) + return X, y + + +@pytest.fixture +def trainable_model(): + """Small model + CCE loss suitable for end-to-end train() tests.""" + return m.Sequential( + b.Dense(4, 8), + a.ReLu(), + b.Dense(8, 2), + a.Softmax(), + loss_class=ls.CategoricalCrossEntropy(), + optimizer="adam", + epochs=3, + batch_size=4, + ) + + +# --------------------------------------------------------------------------- +# TestSequential +# --------------------------------------------------------------------------- + + +class TestSequential: + # --- __init__ defaults --- + + def test_default_optimizer_is_adam(self): + model = m.Sequential() + assert model.optimizer == "adam" + + def test_default_alpha(self): + model = m.Sequential() + assert model.alpha == 0.01 + + def test_default_batch_size(self): + model = m.Sequential() + assert model.batch_size == 1 + + def test_default_epochs(self): + model = m.Sequential() + assert model.epochs == 1000 + + def test_default_loss_is_mse(self): + model = m.Sequential() + assert isinstance(model.loss_class, ls.MeanSquaredError) + + def test_empty_model_has_no_layers(self): + model = m.Sequential() + assert model.layers == [] + + def test_layers_stored_as_list(self, simple_model): + assert isinstance(simple_model.layers, list) + + def test_layer_count_matches_constructor_args(self): + model = m.Sequential(b.Dense(4, 8), a.ReLu(), b.Dense(8, 2)) + assert len(model.layers) == 3 + + def test_layers_preserve_order(self): + d1 = b.Dense(4, 8) + r = a.ReLu() + d2 = b.Dense(8, 2) + model = m.Sequential(d1, r, d2) + assert model.layers[0] is d1 + assert model.layers[1] is r + assert model.layers[2] is d2 + + # --- add --- + + def test_add_increases_layer_count(self, simple_model): + before = len(simple_model.layers) + simple_model.add(a.Sigmoid()) + assert len(simple_model.layers) == before + 1 + + def test_add_appends_to_end(self, simple_model): + sig = a.Sigmoid() + simple_model.add(sig) + assert simple_model.layers[-1] is sig + + def test_add_multiple_layers(self, simple_model): + before = len(simple_model.layers) + simple_model.add(a.ReLu()) + simple_model.add(b.Dense(2, 1)) + assert len(simple_model.layers) == before + 2 + + # --- setoptimizer / setbatchsize / setloss --- + + def test_setoptimizer_updates_attribute(self, simple_model): + simple_model.setoptimizer("sgd") + assert simple_model.optimizer == "sgd" + + def test_setbatchsize_updates_attribute(self, simple_model): + simple_model.setbatchsize(64) + assert simple_model.batch_size == 64 + + def test_setloss_updates_attribute(self, simple_model): + cce = ls.CategoricalCrossEntropy() + simple_model.setloss(cce) + assert simple_model.loss_class is cce + + # --- predict --- + + def test_predict_output_shape_batch(self, simple_model): + X = np.random.randn(8, 4).astype(np.float32) + out = simple_model.predict(X) + assert out.shape == (8, 2) + + def test_predict_output_shape_single_sample(self, simple_model): + X = np.random.randn(1, 4).astype(np.float32) + out = simple_model.predict(X) + assert out.shape == (1, 2) + + def test_predict_returns_numpy_array(self, simple_model): + X = np.random.randn(4, 4).astype(np.float32) + out = simple_model.predict(X) + assert isinstance(out, np.ndarray) + + def test_predict_is_deterministic_without_training(self, simple_model): + X = np.random.randn(4, 4).astype(np.float32) + out1 = simple_model.predict(X) + out2 = simple_model.predict(X) + np.testing.assert_array_equal(out1, out2) + + # --- __call__ --- + + def test_call_matches_predict(self, simple_model): + X = np.random.randn(8, 4).astype(np.float32) + np.testing.assert_array_equal(simple_model(X), simple_model.predict(X)) + + # --- backward --- + + def test_backward_runs_without_error(self, simple_model): + X = np.random.randn(8, 4).astype(np.float32) + simple_model.predict(X) + grad = np.random.randn(8, 2).astype(np.float32) + simple_model.backward(grad) # should not raise + + # --- train --- + + def test_train_returns_list(self, trainable_model, small_data): + X, y = small_data + losses = trainable_model.train(X, y, X, y) + assert isinstance(losses, list) + + def test_train_loss_count_equals_epochs(self, trainable_model, small_data): + X, y = small_data + losses = trainable_model.train(X, y, X, y) + assert len(losses) == trainable_model.epochs + + def test_train_each_entry_is_tuple_of_two(self, trainable_model, small_data): + X, y = small_data + losses = trainable_model.train(X, y, X, y) + assert all(len(entry) == 2 for entry in losses) + + def test_train_losses_are_finite(self, trainable_model, small_data): + X, y = small_data + losses = trainable_model.train(X, y, X, y) + for train_loss, test_loss in losses: + assert np.isfinite(train_loss) + assert np.isfinite(test_loss) + + @pytest.mark.parametrize("optimizer", ["sgd", "adam"]) + def test_train_works_with_both_optimizers(self, small_data, optimizer): + X, y = small_data + model = m.Sequential( + b.Dense(4, 8), + a.ReLu(), + b.Dense(8, 2), + a.Softmax(), + loss_class=ls.CategoricalCrossEntropy(), + optimizer=optimizer, + epochs=2, + batch_size=4, + ) + losses = model.train(X, y, X, y) + assert len(losses) == 2 + + def test_train_raises_on_invalid_optimizer(self, trainable_model, small_data): + X, y = small_data + trainable_model.setoptimizer("invalid_opt") + with pytest.raises(ValueError, match="invalid_opt"): + trainable_model.train(X, y, X, y) + + # --- copy --- + + def test_copy_returns_sequential_instance(self, simple_model): + assert isinstance(simple_model.copy(), m.Sequential) + + def test_copy_has_same_layer_count(self, simple_model): + copy = simple_model.copy() + assert len(copy.layers) == len(simple_model.layers) + + def test_copy_layers_are_different_objects(self, simple_model): + copy = simple_model.copy() + for orig, copied in zip(simple_model.layers, copy.layers): + assert orig is not copied + + def test_copy_dense_weights_are_equal(self, simple_model): + copy = simple_model.copy() + for orig, copied in zip(simple_model.layers, copy.layers): + if isinstance(orig, b.Dense): + np.testing.assert_array_equal(copied.W, orig.W) + np.testing.assert_array_equal(copied.b, orig.b) + + def test_copy_dense_weights_do_not_share_memory(self, simple_model): + copy = simple_model.copy() + for orig, copied in zip(simple_model.layers, copy.layers): + if isinstance(orig, b.Dense): + copied.W[0, 0] += 99.0 + assert orig.W[0, 0] != copied.W[0, 0] + + def test_copy_preserves_alpha(self, simple_model): + copy = simple_model.copy() + assert copy.alpha == simple_model.alpha + + def test_copy_preserves_optimizer(self, simple_model): + copy = simple_model.copy() + assert copy.optimizer == simple_model.optimizer + + def test_copy_preserves_batch_size(self, simple_model): + copy = simple_model.copy() + assert copy.batch_size == simple_model.batch_size + + def test_copy_preserves_epochs(self, simple_model): + copy = simple_model.copy() + assert copy.epochs == simple_model.epochs + + +# --------------------------------------------------------------------------- +# TestSequentialBuilder +# --------------------------------------------------------------------------- + + +class TestSequentialBuilder: + # --- build --- + + def test_build_returns_sequential(self): + assert isinstance(m.SequentialBuilder().build(), m.Sequential) + + def test_build_empty_has_no_layers(self): + model = m.SequentialBuilder().build() + assert len(model.layers) == 0 + + # --- layer builder methods --- + + def test_flatten_adds_flatten_layer(self): + model = m.SequentialBuilder().flatten().build() + assert isinstance(model.layers[0], b.Flatten) + + def test_dense_adds_dense_layer(self): + model = m.SequentialBuilder().dense(4, 8).build() + assert isinstance(model.layers[0], b.Dense) + + def test_dense_sets_input_size(self): + model = m.SequentialBuilder().dense(4, 8).build() + assert model.layers[0].input_size == 4 + + def test_dense_sets_output_size(self): + model = m.SequentialBuilder().dense(4, 8).build() + assert model.layers[0].output_size == 8 + + @pytest.mark.parametrize( + "method, expected_type", + [ + ("relu", a.ReLu), + ("sigmoid", a.Sigmoid), + ("tanh", a.Tanh), + ("softmax", a.Softmax), + ], + ) + def test_activation_methods_add_correct_type(self, method, expected_type): + model = getattr(m.SequentialBuilder(), method)().build() + assert isinstance(model.layers[0], expected_type) + + def test_elu_adds_elu_layer(self): + model = m.SequentialBuilder().elu().build() + assert isinstance(model.layers[0], a.ELU) + + def test_elu_sets_alpha_activation(self): + model = m.SequentialBuilder().elu(alpha_activation=0.5).build() + assert model.layers[0].alpha_activation == 0.5 + + # --- hyperparameter methods --- + + def test_optimizer_sets_correctly(self): + model = m.SequentialBuilder().optimizer("sgd").build() + assert model.optimizer == "sgd" + + def test_alpha_sets_correctly(self): + model = m.SequentialBuilder().alpha(0.001).build() + assert model.alpha == 0.001 + + def test_batch_sets_correctly(self): + model = m.SequentialBuilder().batch(64).build() + assert model.batch_size == 64 + + def test_epochs_sets_correctly(self): + model = m.SequentialBuilder().epochs(50).build() + assert model.epochs == 50 + + def test_loss_sets_correctly(self): + cce = ls.CategoricalCrossEntropy() + model = m.SequentialBuilder().loss(cce).build() + assert model.loss_class is cce + + # --- fluent API --- + + def test_all_builder_methods_return_self(self): + builder = m.SequentialBuilder() + assert builder.flatten() is builder + assert builder.dense(4, 8) is builder + assert builder.relu() is builder + assert builder.sigmoid() is builder + assert builder.tanh() is builder + assert builder.softmax() is builder + assert builder.elu() is builder + assert builder.optimizer("adam") is builder + assert builder.alpha(0.01) is builder + assert builder.batch(32) is builder + assert builder.epochs(100) is builder + assert builder.loss(ls.MeanSquaredError()) is builder + + # --- full chain --- + + def test_full_chain_layer_count(self): + model = ( + m.SequentialBuilder() + .flatten() + .dense(784, 128) + .relu() + .dense(128, 10) + .softmax() + .build() + ) + assert len(model.layers) == 5 + + def test_full_chain_layer_order(self): + model = ( + m.SequentialBuilder() + .flatten() + .dense(784, 128) + .relu() + .dense(128, 10) + .softmax() + .build() + ) + assert isinstance(model.layers[0], b.Flatten) + assert isinstance(model.layers[1], b.Dense) + assert isinstance(model.layers[2], a.ReLu) + assert isinstance(model.layers[3], b.Dense) + assert isinstance(model.layers[4], a.Softmax) + + def test_full_chain_hyperparams(self): + cce = ls.CategoricalCrossEntropy() + model = ( + m.SequentialBuilder() + .dense(4, 2) + .softmax() + .optimizer("adam") + .alpha(0.001) + .batch(64) + .epochs(10) + .loss(cce) + .build() + ) + assert model.optimizer == "adam" + assert model.alpha == 0.001 + assert model.batch_size == 64 + assert model.epochs == 10 + assert model.loss_class is cce + + def test_multiple_builds_are_independent(self): + builder = m.SequentialBuilder().dense(4, 8).relu() + model_a = builder.build() + model_b = builder.build() + assert model_a is not model_b + assert model_a.layers[0] is not model_b.layers[0] diff --git a/tests/test_phitodeep.py b/tests/test_phitodeep.py deleted file mode 100644 index b1c353f..0000000 --- a/tests/test_phitodeep.py +++ /dev/null @@ -1 +0,0 @@ -from phitodeep import phitodeep