# ML Program 3 - Neural Network (Backpropagation from scratch)
import numpy as np

X = np.array(
    [[5, 2], [6, 4], [7, 5], [8, 6], [4, 3], [9, 8], [6, 7], [7, 9], [5, 6], [8, 4]],
    dtype=float)
y = np.array([
    [50], [65], [75], [80], [55], [90], [85], [95], [70], [78]],
    dtype=float)
X = (X - np.mean(X, axis=0)) / np.std(X, axis=0)

class NeuralNetwork:
    def __init__(self, input_size, hidden_size, output_size, lr=0.1):
        self.lr = lr

        self.W1 = np.random.randn(input_size, hidden_size)
        self.b1 = np.zeros((1, hidden_size))

        self.W2 = np.random.randn(hidden_size, output_size)
        self.b2 = np.zeros((1, output_size))

    def relu(self, x):
        return np.maximum(0, x)

    def relu_derivative(self, x):
        return (x > 0).astype(float)

    def forward(self, X):
        self.z1 = np.dot(X, self.W1) + self.b1
        self.a1 = self.relu(self.z1)

        self.z2 = np.dot(self.a1, self.W2) + self.b2
        self.ouput = self.z2

        return self.ouput

    def backward(self, X, y):
        m = X.shape[0]

        error = self.ouput - y

        dW2 = np.dot(self.a1.T, error) / m
        db2 = np.sum(error, axis=0, keepdims=True) / m

        hidden_error = np.dot(error, self.W2.T)

        hidden_delta = hidden_error * self.relu_derivative(self.z1)

        dW1 = np.dot(X.T, hidden_delta) / m
        db1 = np.sum(hidden_delta, axis=0, keepdims=True) / m

        self.W2 -= self.lr * dW2
        self.b2 -= self.lr * db2
        self.W1 -= self.lr * dW1
        self.b1 -= self.lr * db1

    def train(self, X, y, epochs):
        for i in range(epochs):
            self.forward(X)
            self.backward(X, y)

            if i % 1000 == 0:
                loss = np.mean((y - self.ouput) ** 2)
                print(f"Epoch {i},Loss:{loss:.4f}")

nn = NeuralNetwork(input_size=2, hidden_size=5, output_size=1, lr=0.01)
nn.train(X, y, epochs=10000)

print("\nPredicted Scores:")
predictions = nn.forward(X)
print(predictions)

print("\nActual Scores:")
print(y)
