import numpy as np

# Define your activation function (you mentioned no sigmoid, likely linear)
def activation_function(x):
    # No transformation (identity function)
    return x

# Initialize weights with small random values
def initialize_weights(input_size, output_size):
    # Proper small initialization for faster convergence
    return np.random.randn(input_size, output_size) * 0.1

# Forward pass
def forward_pass(input_vector, weights, biases):
    return np.dot(input_vector, weights) + biases

# Training function
def train_neural_network(input_data, target_data, learning_rate=0.001, max_epochs=2000, error_threshold=1e-5):
    input_size = len(input_data[0])
    output_size = len(target_data[0])
    
    # Initialize weights and biases
    weights = initialize_weights(input_size, output_size)
    biases = np.zeros(output_size)

    # Training loop
    for epoch in range(max_epochs):
        total_error = 0
        for i in range(len(input_data)):
            input_vector = input_data[i]
            target_vector = target_data[i]

            # Forward pass
            output = forward_pass(input_vector, weights, biases)
            error = target_vector - output
            total_error += np.sum(error ** 2)

            # Backpropagation (gradient descent)
            weight_gradient = np.outer(input_vector, error)  # Gradient w.r.t weights
            bias_gradient = error  # Gradient w.r.t bias

            # Gradient clipping to avoid exploding gradients
            weight_gradient = np.clip(weight_gradient, -10, 10)
            bias_gradient = np.clip(bias_gradient, -10, 10)

            # Check for NaN in gradients and skip update if found
            if np.isnan(np.sum(weight_gradient)) or np.isnan(np.sum(bias_gradient)):
                print("NaN detected, skipping update.")
                continue

            # Update weights and biases
            weights += learning_rate * weight_gradient
            biases += learning_rate * bias_gradient

        # Early stopping: check if the error is below the threshold
        if total_error < error_threshold:
            print(f"Converged at epoch {epoch} with error: {total_error}")
            break
        
        # Optional: Print status for every 100 epochs
        if epoch % 100 == 0:
            print(f"Epoch {epoch}, Error: {total_error}")
    
    return weights, biases

# Test the network with the provided truth tables
def test_neural_network(weights, biases, input_data):
    predictions = []
    for input_vector in input_data:
        output = forward_pass(input_vector, weights, biases)
        predictions.append(output)
    return predictions

# Define the truth tables
input_data = [
    [0, 0, 0, 0],
    [0, 0, 0, 1],
    [0, 0, 1, 0],
    [0, 0, 1, 1],
    [0, 1, 0, 0],
    [0, 1, 0, 1],
    [0, 1, 1, 0],
    [0, 1, 1, 1],
    [1, 0, 0, 0],
    [1, 0, 0, 1],
    [1, 0, 1, 0],
    [1, 0, 1, 1],
    [1, 1, 0, 0],
    [1, 1, 0, 1],
    [1, 1, 1, 0],
    [1, 1, 1, 1]
]

# Corresponding targets (for the XOR problem)
target_data = [
    [0, 0, 0, 0],
    [0, 0, 0, 1],
    [0, 0, 1, 0],
    [0, 0, 1, 1],
    [0, 1, 0, 0],
    [0, 1, 0, 1],
    [0, 1, 1, 0],
    [0, 1, 1, 1],
    [1, 0, 0, 0],
    [1, 0, 0, 1],
    [1, 0, 1, 0],
    [1, 0, 1, 1],
    [1, 1, 0, 0],
    [1, 1, 0, 1],
    [1, 1, 1, 0],
    [1, 1, 1, 1]
]

# Train the neural network
weights, biases = train_neural_network(input_data, target_data, learning_rate=0.001)

# Test the neural network
predictions = test_neural_network(weights, biases, input_data)

# Display the results
for i, (input_vector, target_vector, prediction) in enumerate(zip(input_data, target_data, predictions)):
    print(f"Table {i+1}: Input: {input_vector}, Target: {target_vector}, Prediction: {prediction}, Error: {np.abs(np.array(target_vector) - np.array(prediction))}")
