In part - 1, I was asked to implement the following. # -*- coding: utf-8 -*-
"""ML_2.ipynb

Automatically generated by Colaboratory.

Original file is located at
    https://colab.research.google.com/drive/1Nlj4SKnJgUNA0rers6Vt1nwNN3mV49c0
"""

import pandas as pd
import torch
import numpy as np
from torch import nn, optim
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from sklearn.metrics import accuracy_score, confusion_matrix
import matplotlib.pyplot as plt
import seaborn as sns

# Step 1: Load the Dataset
data = pd.read_csv('dataset.csv')
data.head()

# Visualize scatter plots for features against the target
sns.scatterplot(data=data, x=data.columns[0], y='target')
plt.title('Feature1 vs Target')
plt.show()

sns.scatterplot(data=data, x=data.columns[1], y='target')
plt.title('Feature2 vs Target')
plt.show()

sns.scatterplot(data=data, x=data.columns[2], y='target')
plt.title('Feature3 vs Target')
plt.show()

"""# **Step 2**: **Preprocessing the Dataset**

"""

# Identify categorical columns
categorical_columns = data.select_dtypes(include=['object']).columns

# Create an empty list to store invalid values
invalid_values = []

# Iterate over all object columns and find the invalid values
for col in categorical_columns:
    invalid_values.extend(data.loc[data[col].str.isalpha(), col].unique())

# Print the object columns & the unique list of invalid values 
print('Categorical columns:' , categorical_columns.to_list(), 'Invalid Values:', set(invalid_values))

# Replace missing/invalid values with pd.NA
data = data.replace(invalid_values, np.nan)

# Find the missing values
missing_values = data.isna().sum()
print('Missing values:')
print(missing_values)

# Fill missing values with mode or mean, depending on column type

fill_columns = [k for k, v in missing_values.to_dict().items() if v != 0]

for col in fill_columns:
    if data[col].dtype == 'object':
        data[col].fillna(data[col].mode()[0], inplace=True)
    else:
        data[col].fillna(data[col].mean(), inplace=True)

# Convert the dataframe to numeric
data = data.astype('float')

# Store Features and Target in X and y respectively
X = data.drop(columns=['target'])
y = data['target']

# Scale numerical variables to have zero mean and unit variance.
scaler = StandardScaler(with_mean= False)
X_scaled = scaler.fit_transform(X)

# Compute the mean and variance of each column
mean = np.mean(X_scaled, axis=0)
var = np.var(X_scaled, axis=0)

print(f'Mean: {mean} Variance: {var}')

# Split the data into train and test sets
X_train, X_test, y_train, y_test = train_test_split(X_scaled, y, test_size=0.2, random_state=42, shuffle= False)

#Step 3 & 4 : Defining the Neural Network and its Architecture

class NNClassifier(nn.Module):
    def __init__(self, input_size, hidden_size, output_size):
        super(NNClassifier, self).__init__()
        self.fc1 = nn.Linear(input_size, hidden_size)
        self.relu1 = nn.ReLU()
        self.fc2 = nn.Linear(hidden_size, hidden_size)
        self.relu2 = nn.ReLU()
        self.fc3 = nn.Linear(hidden_size,hidden_size)
        self.relu3 = nn.ReLU()
        self.fc4 = nn.Linear(hidden_size, output_size)
        self.sigmoid = nn.Sigmoid()

    def forward(self, x):
        x = self.relu1(self.fc1(x))
        x = self.relu2(self.fc2(x))
        x = self.relu3(self.fc3(x))
        x = self.sigmoid(self.fc4(x))
        return x

hidden_size = 128
input_size = X_train.shape[1]
output_size = 1
model = NNClassifier(input_size, hidden_size, output_size)

# Set hyperparameters
epochs = 1000
batch_size = 64
learning_rate = 0.01

# Define loss and optimizer
criterion = nn.BCELoss()
optimizer = optim.SGD(model.parameters(), lr=learning_rate)
#scheduler = StepLR(optimizer, step_size=100, gamma=0.90)


# Training segment
train_losses = []
train_accuracies = []
test_losses = []
test_accuracies = []

for epoch in range(epochs):
    epoch_train_losses = []
    epoch_y_true = []
    epoch_y_pred = []
    
    for i in range(0, len(X_train), batch_size):
        #X_batch = torch.tensor(X_train.iloc[i:i + batch_size].values, dtype=torch.float32)

        X_batch = torch.tensor(X_train[i:i + batch_size], dtype=torch.float)
        y_batch = torch.tensor(y_train[i:i + batch_size].values, dtype=torch.float).view(-1, 1)

        optimizer.zero_grad()
        y_pred = model(X_batch)
        loss = criterion(y_pred, y_batch)
        loss.backward()
        optimizer.step()

        epoch_train_losses.append(loss.item())
        epoch_y_true.extend(y_batch.numpy().flatten().tolist())
        epoch_y_pred.extend((y_pred > 0.5).float().numpy().flatten().tolist())
    
    #scheduler.step() 
    train_losses.append(sum(epoch_train_losses) / len(epoch_train_losses))
    train_accuracies.append(accuracy_score(epoch_y_true, epoch_y_pred))

    # Testing segment
    with torch.no_grad():
        #X_test_tensor = torch.tensor(X_test.values, dtype=torch.float32)
        X_test_tensor = torch.tensor(X_test, dtype=torch.float32)
        y_test_tensor = torch.tensor(y_test.values, dtype=torch.float32).view(-1, 1)

        test_pred = model(X_test_tensor)
        test_loss = criterion(test_pred, y_test_tensor)
        test_accuracy = accuracy_score(y_test_tensor, (test_pred > 0.5).float())

    test_losses.append(test_loss.item())
    test_accuracies.append(test_accuracy)
    if epoch % 100 == 0:
        print(f"Epoch: {epoch+1}/{epochs}, Training Loss: {train_losses[-1]}, Test Loss: {test_loss.item()}, Training Accuracy: {train_accuracies[-1]}, Test Accuracy: {test_accuracy}")

# Compare training and test losses
plt.plot(train_losses, label='Training Loss')
plt.plot(test_losses, label='Test Loss')
plt.title('Training vs Test Loss')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()
plt.show()

# Compare training and test accuracies
plt.plot(train_accuracies, label='Training Accuracy')
plt.plot(test_accuracies, label='Test Accuracy')
plt.title('Training vs Test Accuracy')
plt.xlabel('Epochs')
plt.ylabel('Accuracy')
plt.legend()
plt.show()

#Step 6: Save the Weights

torch.save(model.state_dict(), 'trained_weights.h5')

# Confusion Matrix
y_pred = (model(X_test_tensor) > 0.5).float().numpy()
cm = confusion_matrix(y_test, y_pred)
print('Confusion matrix: \n', cm)
. This BetterNNClassifier is for Part - II which we are about to discuss. I'll provide more details once you get a clear picture of what happend in part - 1. And also before proceeding to part - II , The given implementation is 100%  correct , clean and without any loopholes.
class BetterNNClassifier(nn.Module):
    def __init__(self, input_size, hidden_size, output_size):
        super(BetterNNClassifier, self).__init__()
        self.fc1 = nn.Linear(input_size, hidden_size)
        self.bn1 = nn.BatchNorm1d(hidden_size)
        self.dropout1 = nn.Dropout(0.5)
        self.relu1 = nn.LeakyReLU(0.1)
        self.fc2 = nn.Linear(hidden_size, hidden_size)
        self.bn2 = nn.BatchNorm1d(hidden_size)
        self.dropout2 = nn.Dropout(0.5)
        self.relu2 = nn.LeakyReLU(0.1)
        self.fc3 = nn.Linear(hidden_size, output_size)
        self.sigmoid = nn.Sigmoid()

    def forward(self, x):
        x = self.dropout1(self.bn1(self.relu1(self.fc1(x))))
        x = self.dropout2(self.bn2(self.relu2(self.fc2(x))))
        x = self.sigmoid(self.fc3(x))
        return x



hidden_size = 128
input_size = X_train.shape[1]
output_size = 1
model = BetterNNClassifier(input_size, hidden_size, output_size)