check for issues:
    def train_sgd(self, X_train, y_train, X_val, y_val, epochs=100, learning_rate=0.01, verbose=True):
        training_losses, validation_losses, training_accuracies, validation_accuracies = [], [], [], []
        self.learning_rate = learning_rate

        n_samples = X_train.shape[0]
        for epoch in range(epochs):
            total_loss, correct_predictions = 0, 0

            for x, y_true in zip(X_train, y_train):
                self.propagate_forward(x)
                loss = self.loss_f.function(y_true, self.a[-1])
                total_loss += loss

                if self.is_correct_prediction(self.a[-1], y_true):
                    correct_predictions += 1

                # Get gradients
                dW, db = self.propagate_backward(y_true)

                # Update weights and biases immediately
                for layer in range(1, len(self.W)):
                    self.W[layer] -= self.learning_rate * dW[layer]
                    self.b[layer] -= self.learning_rate * db[layer]

            # Calculate average loss and accuracy for the epoch
            avg_loss = total_loss / n_samples
            accuracy = (correct_predictions / n_samples) * 100
            training_losses.append(avg_loss)
            training_accuracies.append(accuracy)

            # Validation phase
            val_loss, val_correct_predictions = 0, 0
            for i in range(X_val.shape[0]):
                x, y_true = X_val[i], y_val[i]
                self.propagate_forward(x)
                val_loss += self.loss_f.function(y_true, self.a[-1])

                if self.is_correct_prediction(self.a[-1], y_true):
                    val_correct_predictions += 1

            val_avg_loss = val_loss / X_val.shape[0]
            val_accuracy = (val_correct_predictions / X_val.shape[0]) * 100
            validation_losses.append(val_avg_loss)
            validation_accuracies.append(val_accuracy)

            if verbose:
                print(f"Epoch {epoch+1}")
                print(f"Training Loss = {avg_loss:.4f}, Validation Loss = {val_avg_loss:.4f}")
                print(f"Training Accuracy: {accuracy:.2f}%, Validation Accuracy: {val_accuracy:.2f}%")
                print("----------------------------------------------------------------------")

        return training_losses, validation_losses, training_accuracies, validation_accuracies

    def train_batch_gd(self, X_train, y_train, X_val, y_val, epochs=100, learning_rate=0.01, verbose=True):
            training_losses, validation_losses, training_accuracies, validation_accuracies = [], [], [], []
            self.learning_rate = learning_rate
    
            for epoch in range(epochs):
                # Initialize accumulators for gradients
                dW_accum = [None] + [np.zeros_like(w) for w in self.W[1:]]
                db_accum = [None] + [np.zeros_like(b) for b in self.b[1:]]
                total_loss, correct_predictions = 0, 0
    
                for i in range(X_train.shape[0]):
                    x, y_true = X_train[i], y_train[i]
                    self.propagate_forward(x)
                    total_loss += self.loss_f.function(y_true, self.a[-1])
    
                    if self.is_correct_prediction(self.a[-1], y_true):
                        correct_predictions += 1
    
                    # Get gradients instead of updating weights
                    dW, db = self.propagate_backward(y_true)
    
                    # Accumulate gradients
                    for layer in range(1, len(self.W)):
                        dW_accum[layer] += dW[layer]
                        db_accum[layer] += db[layer]
    
                # Average loss and accuracy
                avg_loss = total_loss / X_train.shape[0]
                accuracy = (correct_predictions / X_train.shape[0]) * 100
                training_losses.append(avg_loss)
                training_accuracies.append(accuracy)
    
                # Update weights and biases using averaged gradients
                for layer in range(1, len(self.W)):
                    self.W[layer] -= self.learning_rate * (dW_accum[layer] / X_train.shape[0])
                    self.b[layer] -= self.learning_rate * (db_accum[layer] / X_train.shape[0])
    
                # Validation phase
                val_loss, val_correct_predictions = 0, 0
                for i in range(X_val.shape[0]):
                    x, y_true = X_val[i], y_val[i]
                    self.propagate_forward(x)
                    val_loss += self.loss_f.function(y_true, self.a[-1])
    
                    if self.is_correct_prediction(self.a[-1], y_true):
                        val_correct_predictions += 1
    
                val_avg_loss = val_loss / X_val.shape[0]
                val_accuracy = (val_correct_predictions / X_val.shape[0]) * 100
                validation_losses.append(val_avg_loss)
                validation_accuracies.append(val_accuracy)
    
                if verbose:
                    print(f"Epoch {epoch+1}")
                    print(f"Training Loss = {avg_loss:.4f}, Validation Loss = {val_avg_loss:.4f}")
                    print(f"Training Accuracy: {accuracy:.2f}%, Validation Accuracy: {val_accuracy:.2f}%")
                    print("----------------------------------------------------------------------")
    
            return training_losses, validation_losses, training_accuracies, validation_accuracies

    def train_mini_batch_gd(self, X_train, y_train, X_val, y_val, epochs=100, learning_rate=0.01, batch_size=32, verbose=True):
        training_losses, validation_losses, training_accuracies, validation_accuracies = [], [], [], []
        self.learning_rate = learning_rate
    
        n_samples = X_train.shape[0]
        for epoch in range(epochs):
            # Shuffle the data at the beginning of each epoch (optional but recommended)
            indices = np.arange(n_samples)
            np.random.shuffle(indices)
            X_train_shuffled = X_train[indices]
            y_train_shuffled = y_train[indices]
    
            total_loss, correct_predictions = 0, 0
    
            for start_idx in range(0, n_samples, batch_size):
                end_idx = min(start_idx + batch_size, n_samples)
                batch_X = X_train_shuffled[start_idx:end_idx]
                batch_y = y_train_shuffled[start_idx:end_idx]
    
                # Initialize batch gradients
                batch_dW = [None] + [np.zeros_like(w) for w in self.W[1:]]
                batch_db = [None] + [np.zeros_like(b) for b in self.b[1:]]
    
                batch_loss = 0
                batch_correct = 0
    
                for x, y_true in zip(batch_X, batch_y):
                    self.propagate_forward(x)
                    batch_loss += self.loss_f.function(y_true, self.a[-1])
    
                    if self.is_correct_prediction(self.a[-1], y_true):
                        batch_correct += 1
    
                    # Get gradients
                    dW, db = self.propagate_backward(y_true)
    
                    # Accumulate batch gradients
                    for layer in range(1, len(self.W)):
                        batch_dW[layer] += dW[layer]
                        batch_db[layer] += db[layer]
    
                # Compute average gradients for the mini-batch
                for layer in range(1, len(self.W)):
                    batch_dW[layer] /= (end_idx - start_idx)
                    batch_db[layer] /= (end_idx - start_idx)
    
                # Update weights and biases using mini-batch gradients
                for layer in range(1, len(self.W)):
                    self.W[layer] -= self.learning_rate * batch_dW[layer]
                    self.b[layer] -= self.learning_rate * batch_db[layer]
    
                # Accumulate loss and correct predictions
                total_loss += batch_loss
                correct_predictions += batch_correct
    
            # Average loss and accuracy for the epoch
            avg_loss = total_loss / n_samples
            accuracy = (correct_predictions / n_samples) * 100
            training_losses.append(avg_loss)
            training_accuracies.append(accuracy)
    
            # Validation phase
            val_loss, val_correct_predictions = 0, 0
            for i in range(X_val.shape[0]):
                x, y_true = X_val[i], y_val[i]
                self.propagate_forward(x)
                val_loss += self.loss_f.function(y_true, self.a[-1])
    
                if self.is_correct_prediction(self.a[-1], y_true):
                    val_correct_predictions += 1
    
            val_avg_loss = val_loss / X_val.shape[0]
            val_accuracy = (val_correct_predictions / X_val.shape[0]) * 100
            validation_losses.append(val_avg_loss)
            validation_accuracies.append(val_accuracy)
    
            if verbose:
                print(f"Epoch {epoch+1}")
                print(f"Training Loss = {avg_loss:.4f}, Validation Loss = {val_avg_loss:.4f}")
                print(f"Training Accuracy: {accuracy:.2f}%, Validation Accuracy: {val_accuracy:.2f}%")
                print("----------------------------------------------------------------------")
    
        return training_losses, validation_losses, training_accuracies, validation_accuracies