import os
import pickle
import numpy as np

# ---------------------------
# Data Loading and Preprocessing
# ---------------------------
def load_cifar10_batch(batch_filename):
    with open(batch_filename, 'rb') as f:
        dict = pickle.load(f, encoding='bytes')
        X = dict[b'data']
        Y = dict[b'labels']
        X = X.reshape(len(X), 3, 32, 32).astype('float32')
        Y = np.array(Y)
    return X, Y

def load_cifar10(dir):
    X_train = []
    Y_train = []
    for i in range(1,6):
        batch_name = os.path.join(dir, f'data_batch_{i}')
        X, Y = load_cifar10_batch(batch_name)
        X_train.append(X)
        Y_train.append(Y)
    X_train = np.concatenate(X_train)
    Y_train = np.concatenate(Y_train)
    X_test, Y_test = load_cifar10_batch(os.path.join(dir, 'test_batch'))
    return X_train, Y_train, X_test, Y_test

def preprocess_data(X, Y):
    X = X / 255.0  # Normalize
    Y_one_hot = np.zeros((Y.size, 10))
    Y_one_hot[np.arange(Y.size), Y] = 1
    return X, Y_one_hot

# ---------------------------
# CNN Components
# ---------------------------
class ConvLayer:
    def __init__(self, num_filters, filter_size, input_depth):
        self.num_filters = num_filters
        self.filter_size = filter_size
        self.input_depth = input_depth
        # Initialize filters with small random numbers
        self.filters = np.random.randn(num_filters, input_depth, filter_size, filter_size) * 0.1
        self.biases = np.zeros((num_filters, 1))
    
    def forward(self, input):
        self.input = input
        batch_size, depth, height, width = input.shape
        self.output_height = height - self.filter_size + 1
        self.output_width = width - self.filter_size + 1
        output = np.zeros((batch_size, self.num_filters, self.output_height, self.output_width))
        for i in range(self.output_height):
            for j in range(self.output_width):
                region = input[:, :, i:i+self.filter_size, j:j+self.filter_size]
                # Perform convolution
                for f in range(self.num_filters):
                    output[:, f, i, j] = np.sum(region * self.filters[f, :, :, :], axis=(1,2,3)) + self.biases[f]
        return output
    
    def backward(self, d_out, learning_rate):
        batch_size, _, _, _ = d_out.shape
        d_filters = np.zeros_like(self.filters)
        d_biases = np.zeros_like(self.biases)
        d_input = np.zeros_like(self.input)
        for i in range(self.output_height):
            for j in range(self.output_width):
                region = self.input[:, :, i:i+self.filter_size, j:j+self.filter_size]
                for f in range(self.num_filters):
                    d_filters[f] += np.sum(region * (d_out[:, f, i, j])[:, None, None, None], axis=0)
                    d_biases[f] += np.sum(d_out[:, f, i, j], axis=0)
                    d_input[:, :, i:i+self.filter_size, j:j+self.filter_size] += self.filters[f] * (d_out[:, f, i, j])[:, None, None, None]
        # Update filters and biases
        self.filters -= learning_rate * d_filters / batch_size
        self.biases -= learning_rate * d_biases / batch_size
        return d_input

class ReLU:
    def forward(self, input):
        self.input = input
        return np.maximum(0, input)
    
    def backward(self, d_out):
        d_input = d_out.copy()
        d_input[self.input <= 0] = 0
        return d_input

class MaxPool:
    def __init__(self, size, stride):
        self.size = size
        self.stride = stride
    
    def forward(self, input):
        self.input = input
        batch_size, depth, height, width = input.shape
        self.output_height = (height - self.size) // self.stride + 1
        self.output_width = (width - self.size) // self.stride + 1
        output = np.zeros((batch_size, depth, self.output_height, self.output_width))
        self.max_indices = np.zeros_like(output, dtype=int)
        for i in range(self.output_height):
            for j in range(self.output_width):
                region = input[:, :, i*self.stride:i*self.stride+self.size, j*self.stride:j*self.stride+self.size]
                output[:, :, i, j] = np.max(region, axis=(2,3))
                self.max_indices[:, :, i, j] = np.argmax(region.reshape(batch_size, depth, -1), axis=2)
        return output
    
    def backward(self, d_out):
        batch_size, depth, _, _ = self.input.shape
        d_input = np.zeros_like(self.input)
        for i in range(self.output_height):
            for j in range(self.output_width):
                region = self.input[:, :, i*self.stride:i*self.stride+self.size, j*self.stride:j*self.stride+self.size]
                max_val = np.max(region, axis=(2,3), keepdims=True)
                mask = (region == max_val)
                d_input[:, :, i*self.stride:i*self.stride+self.size, j*self.stride:j*self.stride+self.size] += mask * (d_out[:, :, i, j])[:,:,None,None]
        return d_input

class Flatten:
    def forward(self, input):
        self.input_shape = input.shape
        return input.reshape(self.input_shape[0], -1)
    
    def backward(self, d_out):
        return d_out.reshape(self.input_shape)

# ---------------------------
# BPN Components
# ---------------------------
class Dense:
    def __init__(self, input_size, output_size):
        self.weights = np.random.randn(input_size, output_size) * 0.1
        self.biases = np.zeros((1, output_size))
    
    def forward(self, input):
        self.input = input  # Shape: (batch_size, input_size)
        return np.dot(input, self.weights) + self.biases  # Shape: (batch_size, output_size)
    
    def backward(self, d_out, learning_rate):
        # d_out: (batch_size, output_size)
        d_input = np.dot(d_out, self.weights.T)  # (batch_size, input_size)
        d_weights = np.dot(self.input.T, d_out)  # (input_size, output_size)
        d_biases = np.sum(d_out, axis=0, keepdims=True)  # (1, output_size)
        # Update parameters
        self.weights -= learning_rate * d_weights / self.input.shape[0]
        self.biases -= learning_rate * d_biases / self.input.shape[0]
        return d_input

class Softmax:
    def forward(self, input):
        exps = np.exp(input - np.max(input, axis=1, keepdims=True))
        self.out = exps / np.sum(exps, axis=1, keepdims=True)
        return self.out
    
    def backward(self, Y):
        # Y is one-hot encoded
        return self.out - Y  # (batch_size, num_classes)

class CrossEntropyLoss:
    def compute(self, predictions, targets):
        # predictions: softmax outputs
        # targets: one-hot labels
        self.predictions = predictions
        self.targets = targets
        # Avoid log(0)
        log_preds = np.log(predictions + 1e-15)
        loss = -np.sum(targets * log_preds) / predictions.shape[0]
        return loss

# ---------------------------
# Hybrid Model
# ---------------------------
class HybridModel:
    def __init__(self):
        # CNN Layers
        self.conv = ConvLayer(num_filters=8, filter_size=3, input_depth=3)
        self.relu = ReLU()
        self.pool = MaxPool(size=2, stride=2)
        self.flatten = Flatten()
        
        # BPN Layers
        # After conv and pool, feature maps are 8x15x15 (assuming input 32x32)
        self.dense1 = Dense(input_size=8*15*15, output_size=64)
        self.relu2 = ReLU()
        self.dense2 = Dense(input_size=64, output_size=10)
        self.softmax = Softmax()
        self.loss_fn = CrossEntropyLoss()
    
    def forward(self, X):
        out = self.conv.forward(X)
        out = self.relu.forward(out)
        out = self.pool.forward(out)
        out = self.flatten.forward(out)
        out = self.dense1.forward(out)
        out = self.relu2.forward(out)
        out = self.dense2.forward(out)
        out = self.softmax.forward(out)
        return out
    
    def compute_loss(self, predictions, Y):
        return self.loss_fn.compute(predictions, Y)
    
    def backward(self, predictions, Y, learning_rate):
        # Compute gradient of loss w.r. to output
        d_out = self.softmax.backward(Y)  # (batch_size, 10)
        d_out = self.dense2.backward(d_out, learning_rate)
        d_out = self.relu2.backward(d_out)
        d_out = self.dense1.backward(d_out, learning_rate)
        d_out = self.flatten.backward(d_out)
        d_out = self.pool.backward(d_out)
        d_out = self.relu.backward(d_out)
        d_out = self.conv.backward(d_out, learning_rate)
    
    def predict(self, X):
        out = self.forward(X)
        return np.argmax(out, axis=1)
    
    def evaluate(self, X, Y):
        predictions = self.forward(X)
        loss = self.compute_loss(predictions, Y)
        preds = np.argmax(predictions, axis=1)
        labels = np.argmax(Y, axis=1)
        accuracy = np.mean(preds == labels)
        return loss, accuracy

# ---------------------------
# Training Utilities
# ---------------------------
def get_batches(X, Y, batch_size):
    for i in range(0, X.shape[0], batch_size):
        yield X[i:i+batch_size], Y[i:i+batch_size]

# ---------------------------
# Main Training Loop
# ---------------------------
def train(model, X_train, Y_train, X_test, Y_test, epochs=10, batch_size=32, learning_rate=0.01):
    for epoch in range(epochs):
        print(f"Epoch {epoch+1}/{epochs}")
        batch_losses = []
        for X_batch, Y_batch in get_batches(X_train, Y_train, batch_size):
            # Forward pass
            predictions = model.forward(X_batch)
            loss = model.compute_loss(predictions, Y_batch)
            batch_losses.append(loss)
            # Backward pass
            model.backward(predictions, Y_batch, learning_rate)
        avg_loss = np.mean(batch_losses)
        # Evaluate on training data
        train_loss, train_acc = model.evaluate(X_train[:1000], Y_train[:1000])  # Evaluate on a subset for speed
        # Evaluate on test data
        test_loss, test_acc = model.evaluate(X_test, Y_test)
        print(f"Loss: {avg_loss:.4f} | Train Acc: {train_acc*100:.2f}% | Test Acc: {test_acc*100:.2f}%")
    print("Training Complete")

# ---------------------------
# Putting it all together
# ---------------------------
def main():
    data_dir = r"C:\Users\sai kiran\OneDrive\Desktop\SOFT COMPUTING LAB\cifar-10-batches-py"
    print("Loading CIFAR-10 data...")
    X_train, Y_train, X_test, Y_test = load_cifar10(data_dir)
    print("Preprocessing data...")
    X_train, Y_train = preprocess_data(X_train, Y_train)
    X_test, Y_test = preprocess_data(X_test, Y_test)
    
    # For simplicity, use a subset of data
    X_train = X_train[:5000]
    Y_train = Y_train[:5000]
    X_test = X_test[:1000]
    Y_test = Y_test[:1000]
    
    print("Initializing model...")
    model = HybridModel()
    
    print("Starting training...")
    train(model, X_train, Y_train, X_test, Y_test, epochs=10, batch_size=32, learning_rate=0.01)
    
    # Final Evaluation
    test_loss, test_acc = model.evaluate(X_test, Y_test)
    print(f"Final Test Loss: {test_loss:.4f} | Test Accuracy: {test_acc*100:.2f}%")

if __name__ == "__main__":
    main()





Loading CIFAR-10 data...
Preprocessing data...
Initializing model...
Starting training...
Epoch 1/10
Loss: 2.2929 | Train Acc: 13.20% | Test Acc: 14.30%
Epoch 2/10
Loss: 2.2273 | Train Acc: 20.00% | Test Acc: 21.60%
Epoch 3/10
Loss: 2.1129 | Train Acc: 25.40% | Test Acc: 25.80%
Epoch 4/10
Loss: 2.0034 | Train Acc: 28.80% | Test Acc: 29.10%
Epoch 5/10
Loss: 1.9424 | Train Acc: 31.80% | Test Acc: 32.80%
Epoch 6/10
Loss: 1.8980 | Train Acc: 32.90% | Test Acc: 34.40%
Epoch 7/10
Loss: 1.8590 | Train Acc: 34.40% | Test Acc: 34.90%
Epoch 8/10
Loss: 1.8236 | Train Acc: 37.00% | Test Acc: 36.00%
Epoch 9/10
Loss: 1.7918 | Train Acc: 37.80% | Test Acc: 37.40%
Epoch 10/10
Loss: 1.7631 | Train Acc: 38.80% | Test Acc: 37.80%
Training Complete
Final Test Loss: 1.8005 | Test Accuracy: 37.80%

this is the output please explain me the each and every line of code in detail with perfect explanation where i am having presentation on code explanation so please i neeed the complete explaanation of code  in details and also the hybrid model joining both bpn and cnn and also explain the output also please about the epochs , loss , train acc , test acc , final test loss and test accuracy every thing please do this for me