From 7eea642c6a5edaa27b176c0bc5c6b9a7806fbf77 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:00:09 +0000 Subject: [PATCH 1/3] Initial plan From ded2f83eb74cf6587fe427fb0353835a51cf222a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:04:27 +0000 Subject: [PATCH 2/3] Implement neural network from scratch using NumPy for MNIST digit classification Agent-Logs-Url: https://github.com/Bitu-Singh-Rathoud/neural-network-numpy/sessions/2f9cae04-079e-450e-9133-bd27adca2411 Co-authored-by: Bitu-Singh-Rathoud <247644259+Bitu-Singh-Rathoud@users.noreply.github.com> --- __pycache__/neural_network.cpython-312.pyc | Bin 0 -> 9698 bytes ...eural_network.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 34626 bytes neural_network.py | 241 ++++++++++++++++ requirements.txt | 1 + test_neural_network.py | 272 ++++++++++++++++++ train.py | 131 +++++++++ 6 files changed, 645 insertions(+) create mode 100644 __pycache__/neural_network.cpython-312.pyc create mode 100644 __pycache__/test_neural_network.cpython-312-pytest-9.0.2.pyc create mode 100644 neural_network.py create mode 100644 requirements.txt create mode 100644 test_neural_network.py create mode 100644 train.py diff --git a/__pycache__/neural_network.cpython-312.pyc b/__pycache__/neural_network.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..881246a70595d72b6f26ecd0b2831dc358edfb88 GIT binary patch literal 9698 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 D`>6hs literal 0 HcmV?d00001 diff --git a/__pycache__/test_neural_network.cpython-312-pytest-9.0.2.pyc b/__pycache__/test_neural_network.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0bac5f5cd1479cee2031860413a6f0fc734c9460 GIT binary patch literal 34626 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 literal 0 HcmV?d00001 diff --git a/neural_network.py b/neural_network.py new file mode 100644 index 0000000..dc640ee --- /dev/null +++ b/neural_network.py @@ -0,0 +1,241 @@ +""" +Neural Network implementation from scratch using NumPy. + +Supports arbitrary hidden layer sizes, ReLU activations for hidden layers, +softmax output, and cross-entropy loss trained with mini-batch gradient descent. +""" + +import numpy as np + + +def relu(z): + """Rectified Linear Unit activation.""" + return np.maximum(0, z) + + +def relu_derivative(z): + """Derivative of ReLU.""" + return (z > 0).astype(float) + + +def softmax(z): + """Numerically stable softmax activation.""" + shifted = z - np.max(z, axis=0, keepdims=True) + exp_z = np.exp(shifted) + return exp_z / np.sum(exp_z, axis=0, keepdims=True) + + +def cross_entropy_loss(y_pred, y_true): + """ + Cross-entropy loss between predicted probabilities and one-hot encoded labels. + + Args: + y_pred: (num_classes, batch_size) predicted probabilities. + y_true: (num_classes, batch_size) one-hot encoded true labels. + + Returns: + Scalar average loss. + """ + m = y_true.shape[1] + log_probs = -np.log(np.clip(y_pred, 1e-12, 1.0)) + return np.sum(y_true * log_probs) / m + + +class NeuralNetwork: + """ + Fully-connected neural network trained with gradient descent. + + Architecture: Input -> [Dense + ReLU] * num_hidden_layers -> Dense -> Softmax + + Args: + layer_sizes: List of integers specifying the number of units per layer + including the input and output dimensions. + E.g. [784, 128, 64, 10] creates two hidden layers of + sizes 128 and 64 with a 10-class output. + learning_rate: Step size for gradient descent (default 0.1). + seed: Random seed for reproducibility (default 42). + """ + + def __init__(self, layer_sizes, learning_rate=0.1, seed=42): + np.random.seed(seed) + self.layer_sizes = layer_sizes + self.learning_rate = learning_rate + self.num_layers = len(layer_sizes) - 1 # number of weight matrices + self._init_params() + + # ------------------------------------------------------------------ + # Parameter initialisation + # ------------------------------------------------------------------ + + def _init_params(self): + """He initialisation for weights; zeros for biases.""" + self.params = {} + for l in range(1, self.num_layers + 1): + fan_in = self.layer_sizes[l - 1] + fan_out = self.layer_sizes[l] + self.params[f"W{l}"] = np.random.randn(fan_out, fan_in) * np.sqrt(2.0 / fan_in) + self.params[f"b{l}"] = np.zeros((fan_out, 1)) + + # ------------------------------------------------------------------ + # Forward propagation + # ------------------------------------------------------------------ + + def forward(self, X): + """ + Compute forward pass through the network. + + Args: + X: (input_size, batch_size) input matrix. + + Returns: + Tuple (output probabilities, cache dict for backprop). + """ + cache = {"A0": X} + A = X + for l in range(1, self.num_layers + 1): + W = self.params[f"W{l}"] + b = self.params[f"b{l}"] + Z = W @ A + b + if l < self.num_layers: + A = relu(Z) + else: + A = softmax(Z) + cache[f"Z{l}"] = Z + cache[f"A{l}"] = A + return A, cache + + # ------------------------------------------------------------------ + # Backpropagation + # ------------------------------------------------------------------ + + def backward(self, y_true, cache): + """ + Compute gradients via backpropagation. + + Args: + y_true: (num_classes, batch_size) one-hot encoded labels. + cache: Dict produced by forward(). + + Returns: + Dict of gradients keyed by 'W1', 'b1', …, 'WL', 'bL'. + """ + grads = {} + m = y_true.shape[1] + L = self.num_layers + + # Gradient of loss w.r.t. softmax output (combined softmax + CE) + dA = (cache[f"A{L}"] - y_true) / m + + for l in reversed(range(1, L + 1)): + A_prev = cache[f"A{l - 1}"] + W = self.params[f"W{l}"] + Z = cache[f"Z{l}"] + + if l < L: + dZ = dA * relu_derivative(Z) + else: + dZ = dA # Already the combined gradient for softmax + CE + + grads[f"W{l}"] = dZ @ A_prev.T + grads[f"b{l}"] = np.sum(dZ, axis=1, keepdims=True) + dA = W.T @ dZ + + return grads + + # ------------------------------------------------------------------ + # Parameter update + # ------------------------------------------------------------------ + + def update_params(self, grads): + """Gradient descent parameter update.""" + for l in range(1, self.num_layers + 1): + self.params[f"W{l}"] -= self.learning_rate * grads[f"W{l}"] + self.params[f"b{l}"] -= self.learning_rate * grads[f"b{l}"] + + # ------------------------------------------------------------------ + # Training + # ------------------------------------------------------------------ + + def train(self, X_train, y_train, epochs=20, batch_size=64, verbose=True): + """ + Train the network using mini-batch gradient descent. + + Args: + X_train: (input_size, num_samples) training data. + y_train: (num_classes, num_samples) one-hot labels. + epochs: Number of passes over the training set. + batch_size: Mini-batch size. + verbose: Print loss/accuracy each epoch when True. + + Returns: + Dict with 'loss' and 'accuracy' lists (one value per epoch). + """ + history = {"loss": [], "accuracy": []} + m = X_train.shape[1] + + for epoch in range(1, epochs + 1): + # Shuffle + permutation = np.random.permutation(m) + X_shuffled = X_train[:, permutation] + y_shuffled = y_train[:, permutation] + + epoch_loss = 0.0 + num_batches = 0 + + for start in range(0, m, batch_size): + X_batch = X_shuffled[:, start: start + batch_size] + y_batch = y_shuffled[:, start: start + batch_size] + + probs, cache = self.forward(X_batch) + loss = cross_entropy_loss(probs, y_batch) + grads = self.backward(y_batch, cache) + self.update_params(grads) + + epoch_loss += loss + num_batches += 1 + + epoch_loss /= num_batches + epoch_acc = self.evaluate(X_train, y_train) + history["loss"].append(epoch_loss) + history["accuracy"].append(epoch_acc) + + if verbose: + print( + f"Epoch {epoch:>3}/{epochs} " + f"loss: {epoch_loss:.4f} " + f"train_acc: {epoch_acc:.4f}" + ) + + return history + + # ------------------------------------------------------------------ + # Inference + # ------------------------------------------------------------------ + + def predict(self, X): + """ + Return predicted class indices for each sample. + + Args: + X: (input_size, num_samples) input matrix. + + Returns: + 1-D array of predicted class labels. + """ + probs, _ = self.forward(X) + return np.argmax(probs, axis=0) + + def evaluate(self, X, y_true): + """ + Compute classification accuracy. + + Args: + X: (input_size, num_samples) input matrix. + y_true: (num_classes, num_samples) one-hot labels. + + Returns: + Accuracy in [0, 1]. + """ + predictions = self.predict(X) + labels = np.argmax(y_true, axis=0) + return np.mean(predictions == labels) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e0485aa --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +numpy>=1.21 diff --git a/test_neural_network.py b/test_neural_network.py new file mode 100644 index 0000000..c52fe21 --- /dev/null +++ b/test_neural_network.py @@ -0,0 +1,272 @@ +""" +Unit tests for the NumPy neural network implementation. + +Tests cover: + - Activation functions (relu, softmax) + - Loss computation (cross_entropy_loss) + - Parameter initialisation + - Forward propagation shapes and output validity + - Backward propagation (gradient shapes, numerical gradient check) + - Parameter update + - Train / evaluate helpers on a toy dataset +""" + +import numpy as np +import pytest + +from neural_network import ( + NeuralNetwork, + cross_entropy_loss, + relu, + relu_derivative, + softmax, +) + +# --------------------------------------------------------------------------- +# Activation function tests +# --------------------------------------------------------------------------- + +class TestRelu: + def test_positive_values_unchanged(self): + x = np.array([1.0, 2.0, 3.0]) + np.testing.assert_array_equal(relu(x), x) + + def test_negative_values_zeroed(self): + x = np.array([-1.0, -0.5, 0.0]) + np.testing.assert_array_equal(relu(x), np.array([0.0, 0.0, 0.0])) + + def test_mixed_values(self): + x = np.array([-2.0, 0.0, 3.0]) + expected = np.array([0.0, 0.0, 3.0]) + np.testing.assert_array_equal(relu(x), expected) + + def test_derivative_positive(self): + x = np.array([1.0, 2.0, 0.1]) + np.testing.assert_array_equal(relu_derivative(x), np.ones(3)) + + def test_derivative_negative(self): + x = np.array([-1.0, -0.5]) + np.testing.assert_array_equal(relu_derivative(x), np.zeros(2)) + + def test_derivative_zero(self): + # Subgradient at 0 should be 0 (our implementation) + assert relu_derivative(np.array([0.0]))[0] == 0.0 + + +class TestSoftmax: + def test_output_sums_to_one(self): + z = np.random.randn(5, 10) + out = softmax(z) + np.testing.assert_allclose(out.sum(axis=0), np.ones(10), atol=1e-6) + + def test_all_outputs_positive(self): + z = np.random.randn(5, 10) + assert np.all(softmax(z) > 0) + + def test_numerical_stability_large_values(self): + z = np.array([[1000.0], [1001.0], [999.0]]) + out = softmax(z) + np.testing.assert_allclose(out.sum(), 1.0, atol=1e-6) + + def test_uniform_input(self): + z = np.zeros((4, 3)) + expected = np.full((4, 3), 0.25) + np.testing.assert_allclose(softmax(z), expected, atol=1e-6) + + +# --------------------------------------------------------------------------- +# Loss function tests +# --------------------------------------------------------------------------- + +class TestCrossEntropyLoss: + def test_perfect_prediction_low_loss(self): + y_pred = np.array([[1.0, 0.0], [0.0, 1.0]]) + y_true = np.array([[1.0, 0.0], [0.0, 1.0]]) + loss = cross_entropy_loss(y_pred, y_true) + assert loss < 1e-10 + + def test_loss_positive(self): + y_pred = softmax(np.random.randn(10, 32)) + y_true = np.eye(10)[:, np.random.randint(0, 10, 32)] + assert cross_entropy_loss(y_pred, y_true) > 0 + + def test_worse_prediction_higher_loss(self): + y_true = np.array([[1.0, 0.0], [0.0, 1.0]]) + y_good = np.array([[0.9, 0.1], [0.1, 0.9]]) + y_bad = np.array([[0.1, 0.9], [0.9, 0.1]]) + assert cross_entropy_loss(y_good, y_true) < cross_entropy_loss(y_bad, y_true) + + +# --------------------------------------------------------------------------- +# NeuralNetwork: initialisation +# --------------------------------------------------------------------------- + +class TestNeuralNetworkInit: + def test_param_shapes(self): + nn = NeuralNetwork([4, 8, 3]) + assert nn.params["W1"].shape == (8, 4) + assert nn.params["b1"].shape == (8, 1) + assert nn.params["W2"].shape == (3, 8) + assert nn.params["b2"].shape == (3, 1) + + def test_biases_initialised_to_zero(self): + nn = NeuralNetwork([4, 8, 3]) + np.testing.assert_array_equal(nn.params["b1"], np.zeros((8, 1))) + np.testing.assert_array_equal(nn.params["b2"], np.zeros((3, 1))) + + def test_three_hidden_layers(self): + nn = NeuralNetwork([10, 16, 8, 4, 2]) + for l in range(1, 5): + assert f"W{l}" in nn.params + assert f"b{l}" in nn.params + + +# --------------------------------------------------------------------------- +# NeuralNetwork: forward propagation +# --------------------------------------------------------------------------- + +class TestForwardProp: + def setup_method(self): + self.nn = NeuralNetwork([4, 8, 3], seed=0) + + def test_output_shape(self): + X = np.random.randn(4, 16) + probs, _ = self.nn.forward(X) + assert probs.shape == (3, 16) + + def test_output_sums_to_one(self): + X = np.random.randn(4, 16) + probs, _ = self.nn.forward(X) + np.testing.assert_allclose(probs.sum(axis=0), np.ones(16), atol=1e-6) + + def test_output_all_positive(self): + X = np.random.randn(4, 16) + probs, _ = self.nn.forward(X) + assert np.all(probs > 0) + + def test_cache_keys_present(self): + X = np.random.randn(4, 5) + _, cache = self.nn.forward(X) + expected_keys = {"A0", "Z1", "A1", "Z2", "A2"} + assert expected_keys == set(cache.keys()) + + +# --------------------------------------------------------------------------- +# NeuralNetwork: backward propagation +# --------------------------------------------------------------------------- + +class TestBackwardProp: + def setup_method(self): + self.nn = NeuralNetwork([4, 8, 3], seed=1) + + def test_gradient_shapes(self): + X = np.random.randn(4, 10) + Y = np.eye(3)[:, np.random.randint(0, 3, 10)] + _, cache = self.nn.forward(X) + grads = self.nn.backward(Y, cache) + assert grads["W1"].shape == (8, 4) + assert grads["b1"].shape == (8, 1) + assert grads["W2"].shape == (3, 8) + assert grads["b2"].shape == (3, 1) + + def test_numerical_gradient_check(self): + """Verify analytical gradients match finite-difference approximations.""" + np.random.seed(99) + nn = NeuralNetwork([3, 4, 2], seed=99) + X = np.random.randn(3, 5) + Y = np.eye(2)[:, np.random.randint(0, 2, 5)] + eps = 1e-5 + + probs, cache = nn.forward(X) + grads = nn.backward(Y, cache) + + for key in ["W1", "b1", "W2", "b2"]: + param = nn.params[key] + numerical_grad = np.zeros_like(param) + it = np.nditer(param, flags=["multi_index"], op_flags=["readwrite"]) + while not it.finished: + idx = it.multi_index + orig = param[idx] + param[idx] = orig + eps + p1, _ = nn.forward(X) + loss_plus = cross_entropy_loss(p1, Y) + param[idx] = orig - eps + p2, _ = nn.forward(X) + loss_minus = cross_entropy_loss(p2, Y) + numerical_grad[idx] = (loss_plus - loss_minus) / (2 * eps) + param[idx] = orig + it.iternext() + + np.testing.assert_allclose( + grads[key], numerical_grad, rtol=1e-4, atol=1e-6, + err_msg=f"Gradient check failed for {key}" + ) + + +# --------------------------------------------------------------------------- +# NeuralNetwork: parameter update +# --------------------------------------------------------------------------- + +class TestParamUpdate: + def test_params_change_after_update(self): + nn = NeuralNetwork([4, 8, 3], seed=2) + W1_before = nn.params["W1"].copy() + grads = { + "W1": np.ones((8, 4)), + "b1": np.ones((8, 1)), + "W2": np.ones((3, 8)), + "b2": np.ones((3, 1)), + } + nn.update_params(grads) + assert not np.allclose(nn.params["W1"], W1_before) + + def test_update_magnitude(self): + lr = 0.5 + nn = NeuralNetwork([2, 2], learning_rate=lr, seed=3) + W1_before = nn.params["W1"].copy() + grad = np.ones((2, 2)) + grads = {"W1": grad, "b1": np.zeros((2, 1))} + nn.update_params(grads) + np.testing.assert_allclose(nn.params["W1"], W1_before - lr * grad) + + +# --------------------------------------------------------------------------- +# NeuralNetwork: end-to-end training on toy data +# --------------------------------------------------------------------------- + +class TestEndToEnd: + def _make_xor_data(self, n=200, seed=0): + """Simple 2-class linearly-inseparable toy dataset.""" + rng = np.random.default_rng(seed) + X = rng.standard_normal((2, n)) + labels = ((X[0] * X[1]) > 0).astype(int) + Y = np.eye(2)[:, labels] + return X, Y + + def test_loss_decreases(self): + X, Y = self._make_xor_data() + nn = NeuralNetwork([2, 16, 2], learning_rate=0.1, seed=7) + history = nn.train(X, Y, epochs=50, batch_size=32, verbose=False) + # Loss should be lower at the end than at the start + assert history["loss"][-1] < history["loss"][0] + + def test_accuracy_above_random(self): + X, Y = self._make_xor_data() + nn = NeuralNetwork([2, 32, 2], learning_rate=0.1, seed=7) + nn.train(X, Y, epochs=100, batch_size=32, verbose=False) + acc = nn.evaluate(X, Y) + # A trained network should do better than random (0.5 for 2 classes) + assert acc > 0.5 + + def test_predict_shape(self): + X, Y = self._make_xor_data(n=30) + nn = NeuralNetwork([2, 8, 2], seed=5) + preds = nn.predict(X) + assert preds.shape == (30,) + assert set(preds).issubset({0, 1}) + + def test_evaluate_returns_scalar_in_range(self): + X, Y = self._make_xor_data(n=50) + nn = NeuralNetwork([2, 8, 2], seed=5) + acc = nn.evaluate(X, Y) + assert 0.0 <= acc <= 1.0 diff --git a/train.py b/train.py new file mode 100644 index 0000000..372b40c --- /dev/null +++ b/train.py @@ -0,0 +1,131 @@ +""" +Train a NumPy neural network on the MNIST dataset and report accuracy. + +Usage: + python train.py + +The script: + 1. Downloads / loads the MNIST dataset. + 2. Pre-processes the images (normalise, flatten). + 3. Trains the network for a configurable number of epochs. + 4. Evaluates and prints the final test accuracy. +""" + +import numpy as np + +from neural_network import NeuralNetwork + +# --------------------------------------------------------------------------- +# Hyper-parameters +# --------------------------------------------------------------------------- +LAYER_SIZES = [784, 128, 64, 10] +LEARNING_RATE = 0.1 +EPOCHS = 20 +BATCH_SIZE = 64 +SEED = 42 + + +# --------------------------------------------------------------------------- +# Data loading helpers +# --------------------------------------------------------------------------- + +def load_mnist(): + """ + Load MNIST returning train/test splits as NumPy arrays. + + The function tries several backends in order: + 1. tensorflow.keras (if TensorFlow is installed) + 2. sklearn fetch_openml (downloads the dataset on first run) + + Returns: + (X_train, y_train, X_test, y_test) where + X_*: float32 arrays of shape (60000/10000, 784) in [0, 1] + y_*: int arrays of shape (60000/10000,) with class labels 0-9 + """ + # ---- Option 1: TensorFlow / Keras ---- + try: + from tensorflow.keras.datasets import mnist # type: ignore + (X_train, y_train), (X_test, y_test) = mnist.load_data() + X_train = X_train.reshape(-1, 784).astype(np.float32) / 255.0 + X_test = X_test.reshape(-1, 784).astype(np.float32) / 255.0 + print("Loaded MNIST via TensorFlow/Keras.") + return X_train, y_train, X_test, y_test + except Exception: + pass + + # ---- Option 2: scikit-learn openml ---- + try: + from sklearn.datasets import fetch_openml # type: ignore + print("Downloading MNIST via scikit-learn (this may take a moment)…") + mnist = fetch_openml("mnist_784", version=1, as_frame=False, parser="auto") + X = mnist.data.astype(np.float32) / 255.0 + y = mnist.target.astype(np.int32) + X_train, y_train = X[:60000], y[:60000] + X_test, y_test = X[60000:], y[60000:] + print("Loaded MNIST via scikit-learn.") + return X_train, y_train, X_test, y_test + except Exception as exc: + raise RuntimeError( + "Could not load MNIST. Please install tensorflow or scikit-learn.\n" + f" pip install tensorflow OR pip install scikit-learn\n" + f"Original error: {exc}" + ) from exc + + +def one_hot_encode(y, num_classes=10): + """Convert integer label vector to one-hot matrix (num_classes x num_samples).""" + m = len(y) + oh = np.zeros((num_classes, m), dtype=np.float32) + oh[y, np.arange(m)] = 1.0 + return oh + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main(): + np.random.seed(SEED) + + # -- Load data ----------------------------------------------------------- + X_train, y_train, X_test, y_test = load_mnist() + + # Transpose to (features, samples) convention expected by the network + X_train = X_train.T # (784, 60000) + X_test = X_test.T # (784, 10000) + + Y_train = one_hot_encode(y_train) # (10, 60000) + Y_test = one_hot_encode(y_test) # (10, 10000) + + print( + f"\nDataset: {X_train.shape[1]} training samples, " + f"{X_test.shape[1]} test samples." + ) + + # -- Build & train network ----------------------------------------------- + print(f"\nNetwork architecture: {LAYER_SIZES}") + print(f"Learning rate: {LEARNING_RATE}, Epochs: {EPOCHS}, Batch size: {BATCH_SIZE}\n") + + model = NeuralNetwork( + layer_sizes=LAYER_SIZES, + learning_rate=LEARNING_RATE, + seed=SEED, + ) + + history = model.train( + X_train, + Y_train, + epochs=EPOCHS, + batch_size=BATCH_SIZE, + verbose=True, + ) + + # -- Final evaluation ---------------------------------------------------- + test_acc = model.evaluate(X_test, Y_test) + print(f"\nFinal test accuracy: {test_acc * 100:.2f}%") + + return history, test_acc + + +if __name__ == "__main__": + main() From f8c6a3400eba2113523cba0fe8aab8993b33df6b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:04:52 +0000 Subject: [PATCH 3/3] Add .gitignore to exclude pycache and build artifacts Agent-Logs-Url: https://github.com/Bitu-Singh-Rathoud/neural-network-numpy/sessions/2f9cae04-079e-450e-9133-bd27adca2411 Co-authored-by: Bitu-Singh-Rathoud <247644259+Bitu-Singh-Rathoud@users.noreply.github.com> --- .gitignore | 8 ++++++++ __pycache__/neural_network.cpython-312.pyc | Bin 9698 -> 0 bytes ..._neural_network.cpython-312-pytest-9.0.2.pyc | Bin 34626 -> 0 bytes 3 files changed, 8 insertions(+) create mode 100644 .gitignore delete mode 100644 __pycache__/neural_network.cpython-312.pyc delete mode 100644 __pycache__/test_neural_network.cpython-312-pytest-9.0.2.pyc diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..27b878d --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ +*.egg-info/ +dist/ +build/ +.env diff --git a/__pycache__/neural_network.cpython-312.pyc b/__pycache__/neural_network.cpython-312.pyc deleted file mode 100644 index 881246a70595d72b6f26ecd0b2831dc358edfb88..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9698 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 D`>6hs diff --git a/__pycache__/test_neural_network.cpython-312-pytest-9.0.2.pyc b/__pycache__/test_neural_network.cpython-312-pytest-9.0.2.pyc deleted file mode 100644 index 0bac5f5cd1479cee2031860413a6f0fc734c9460..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34626 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