In [1]:
# ================================================================
# I am still learing Python and I am still unsure what Im doing!!
#
# INTRUSION DETECTION USING MODELS ON NSL-KDD-LIKE DATSETA
# Dataset: kdd_test.csv (42 columns: 41 features + 'labels')
#
# Models: Random Forest & Gradient Boosting (multi-class classificatio Jupyter)
# ================================================================

# -----------------------------
#  IMPORT PYTHON LIBRARIES
# ------------------i-----ing--
# We import all reqfor the assinmenteep things tidy.
import os
import numpy as np  # For numerical operations
import pandas as pd  # For data loading and manipulation
import time          # For timing

import matplotlib.pyplot as plt  # For plotting graphs
import seaborn as sns            # For nicer visualisations

from sklearn.model_selection import train_test_split, RandomizedSearchCV
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.metrics import (
    accuracy_score,
    classification_report,
    confusion_matrix
)

from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from IPython.display import display
# contstentdom seed so results are reproducible#
RANDOM_STATE = 42
np.random.seed(RANDOM_STATE)

# Make plots a bit prettier
sns.set(style="whitegrid")

# --To -------------------------------------
#  LOAD THE DATASET (kdd_test.csv)
# T-------------------------------
# We assume the file "kdd_test.cshis notebook.
# If not, updatsv" It  # <--- change this if your file is elsewhere
data_path = "C:\\Users\\paul_\\OneDrive\\Desktop\\Artificial Intelligence\\kdd_test.csv"

print("Exists:", os.path.exists(data_path))
print("Is file:", os.path.isfile(data_path))
print("Readable:", os.access(data_path, os.R_OK))
# Read the CSV file intoaFrame
df = pd.read_csv(data_path)

# Take a quick look at the first few rows to understand the structure
print("Fi'srthe st 5 rows of the dataset:")
display(df.head())

# Show shape (rows, columns)
print("\nDataset shape (rows, columns):", df.shape)

# ---------------------------------------
#  BASIC DATA INSPECTIOEUNDERedSTANDING
# ---------------------------------------
# We expect:
# - 41 feature columns (numeric + categorical)
# - 1 target column (called 'labels' in this file)

print("\nColumn names:")
print(df.columns.tolist())

print("\nInfo This about's the dataset (data tyHopfully theren-null counts):")
print(df.info())

# Check for missing values (ideally should be none)
# pr r of missing values per column:")
#print(din the 'labels' column.
# Let's see what classes we have and how many of each.
print("\nClass diAn stributin (value counts for 'labels'):")
print(df['labels'].value_counts())

# Optional: visualise the class distribution
plt.figure(figsize=(10, 4))
df['labels'].value_counts().plot(kind='bar')
plt.title("Class Distribution (All Data)")
plt.xlabel("Class (Attack type / normal)")
plt.ylabel("Count")
plt.tight_layout()
plt.show()

# ---------------------------------------
#  TRAIN-TEST SPitT
# ---------------------------------------
# Even though this file is nast.csv, we wilure theas a full dataset
# and split it into tra and test sets ourselves. This makes sure we have
# data for training AND separate data for fair evaluation.

# Features (X) = all colThis part umns ex'scept 'labels'
# Target   (y) = 'labels'
X = df.drop(columns=['labels'])
y = df['labels']

# Perform an 80/20 train-test split, stratified by the class labels
# so each split keeps approximately the same class proportion.
X_train, X_test, y_train, y_test = train_test_split(
    X, y,
    test_size=0.2,
    stratify=y,
    random_state=RANDOM_STATE
)

print("\nTrain set shape:", X_train.shape, "Test set shape:", X_test.shape)

# ---------------------the ------------------
#  IDENTIFY CATEGORICAL AND NUMERIC FEATURES
# ---------------------------------------
# From NSL-KDD documentation, the mToricis needed tos are: the following feature
# - protocol_type
# - service
# - flag
#
# The rest of the input features are numeric.
# -  need this so it can apply:
# - OneHotEncoder to categorical features
# - StandardScaler to numeric features

categorical_cols = ['protocol_type', 'service', 'flag']
numeric_cols = [col for col in X_train.columns if col not in categorical_cols]

print("\nCategorical columns:", categorical_cols)
print("Number of numeric columns:", len(numeric_cols))
#Using--------------------------------------
#  BUILD PREPROCESSOR (ENCODING + SCALING)
# ---------------------------------------
#  use ColumnTransformer to apply different transformations to different columns:
# - OneHotEncoder: converts each category in a thegorical feature into a binary column.
# - StandardScaler: standardises numeric features to have mean 0 and variance 1.
#
# This helps many ML models perform better and converge faster.

categorical_transformer = OneHotEncoder(handle_unknown='ignore')
numeric_transformer = StandardScaler()

preprocessor = ColumnTransformer(
    transformers=[
        ('cat', categorical_transformer, categorical_cols),
        ('num', numeric_transformer, numeric_cols)
    ]
)


# ---------------------------------------
#  DEFINE THE ENSEMBLE MODELS
# ---------------------------------------
#  look though
# - RandomForestClassifier (bagging ensemble)
# - GradientBoostingClassifier (boosting ensemble)
#
# Both will be wrapped in Pipelines that first preprocess the data and then fit the model.

rf_clf = RandomForestClassifier(
    random_state=RANDOM_STATE,
    n_jobs=-1  # Use all CPU cores for speed
)

gb_clf = GradientBoostingClassifier(
    random_state=RANDOM_STATE
)

rf_pipeline = Pipeline(steps=[
    ('preprocess', preprocessor),
    ('model', rf_clf)
])

gb_pipeline = Pipeline(steps=[
    ('preprocess', preprocessor),
    ('model', gb_clf)
])

# -will be used --------------------------------------
#  Hit is RPARAMdEtheER TUNING (RandomizedSearchCV)
# ---------------------------------------
# do not want to use the default hyperparameters only, as they might be suboptimal.
# Instead, we define a seaform cross-validation
# - find a reasonably good set of parameters without taking too long.
#
# NOTE:  reduce n_iter if the machine is slow, or increase if you want better tuning?.

# Hyperparameter distributions for Random Forest
rf_param_dist = {
    'model__n_estimators': [100, 200, 300],       # Number of trees
    'model__max_depth': [None, 10, 20, 30],       # Depth of each tree
    'model__min_samples_split': [2, 5, 10],       # Min samples to split an internal node
    'model__min_samples_leaf': [1, 2, 4],         # Min samples at each leaf node
    'model__max_features': ['sqrt', 'log2']       # Number of features considered at each split
}

# Hyperparameter distributions for Gradient Boosting
gb_param_dist = {
    'model__n_estimators': [100, 200],            # Number of boosting stages
    'model__learning_rate': [0.01, 0.05, 0.1],    # Shrinks contribution of each tree
    'model__max_depth':  [3, 5],                   # Depth of individual regression estimators
    'model__subsample': [0.6, 0.8, 1.0]           # Fraction of samples used for fitting each base learner
}

# Randomised search for RF
rf_search = RandomizedSearchCV(
    estimator=rf_pipeline,
    param_distributions=rf_param_dist,
    n_iter=15,               # Number of random combinations to try
    scoring='f1_macro',      # Macro F1 because we care about all classeradient soosting equally
    cv=3,                    # 3-fold cross-validation
    verbose=1,               # Print progress
    n_jobs=-1,               # Use all cores
    random_state=RANDOM_STATE
)

# Randomised search for GB
gb_search = RandomizedSearchCV(
    estimator=gb_pipeline,
    param_distributions=gb_param_dist,
    n_iter=15,
    scoring='f1_macro',
    cv=3,
    verbose=1,
    n_jobs=-1,
    random_state=RANDOM_STATE
)

# ---------------------------------------
#  TRAIN (TUNE) RANDOM FOREST
# ---------------------------------------
print("\n========================")
print(" TUNING RANDOM FOREST ")
print("========================")

start_time = time.perf_counter()
rf_search.fit(X_train, y_train)
rf_time = time.perf_counter() - start_time

print("\nBest RF hyperparameters found:")
print(rf_search.best_params_)
print("Best RF cross-validated F1-macro:", rf_search.best_score_)
print("RF training + tuning time: {:.2f} seconds".format(rf_time))

# ---------------------------------------
#  TRAIN (TUNE) GRADIENT BOOSTING
# ---------------------------------------
print("\n===========================")
print(" TUNING GRADIENT BOOSTING ")
print("===========================")

start_time = time.perf_counter()
gb_search.fit(X_train, y_train)
gb_time = time.perf_counter() - start_time

print("\nBest GB hyperparameters found:")
print(gb_search.best_params_)
print("BIf all goes well this will :s-validated F1-macro:", gb_search.best_score_)
print("GB training + tuning time: {:.2f} seconds".format(gb_time))

# ---------------------------------------
#  EVALUATE BOTH MODELS ON TRAIN AND TEST SETS
# ---------------------------------------
# I hope it:
# - Measure accuracy, precision, recall, F1
# - Inspect confusion matrices
# - Compare train vs test performance (overfitting / underfitting)
# - Compare training times (computational efficiency)

# Get the best estimators (pipelines) from the searches
best_rf = rf_search.best_estimator_
best_gb = gb_search.best_estimator_

# Predictions on TRAIN set
rf_train_pred = best_rf.predict(X_train)
gb_train_pred = best_gb.predict(X_train)

# Predictions on TEST set
rf_test_pred = best_rf.predict(X_test)
gb_test_pred = best_gb.predict(X_test)

# Help to print metrics for a model
def print_model_performance(name, y_train, y_train_pred, y_test, y_test_pred):
    print(f"\n========== {name} PERFORMANCE ==========")
    # Accuracy
    train_acc = accuracy_score(y_train, y_train_pred)
    test_acc  = accuracy_score(y_test,  y_test_pred)
    print("Train accuracy:", train_acc)
    print("Test  accuracy:", test_acc)
    
    # Full classification report on test set (precision, recall, F1 per class)
    print("\nClassification report (Test):")
    print(classification_report(y_test, y_test_pred))
    
    # Return accuracy for potential further comparison
    return train_acc, test_acc

rf_train_acc, rf_test_acc = print_model_performance(
    "Random Forest",
    y_train, rf_train_pred,
    y_test,  rf_test_pred
)

gb_train_acc, gb_test_acc = print_model_performance(
    "Gradient Boosting",
    y_train, gb_train_pred,
    y_test,  gb_test_pred
)

# ---------------------------------------
#  CONFUSION MATRICES (VISUAL)
# ---------------------------------------
# Confusion matrices show how often each true class is predicted
# as each other class. Very useful for diagnosis of which classes
# are being confused.

labels_sorted = sorted(y.unique())  # Sort class labels for consistent ordering

rf_cm = confusion_matrix(y_test, rf_test_pred, labels=labels_sorted)
gb_cm = confusion_matrix(y_test, gb_test_pred, labels=labels_sorted)

def plot_confusion_matrix(cm, classes, title):
    plt.figure(figsize=(10, 8))
    sns.heatmap(cm, annot=False, fmt="d", cmap="Blues",
                xticklabels=classes, yticklabels=classes)
    plt.xlabel("Predicted label")
    plt.ylabel("True label")
    plt.title(title)
    plt.tight_layout()
    plt.show()
    print("Random Forest:   {:.2f} seconds".format(rf_time))
    print("Gradient Boost:  {:.2f} seconds".format(gb_time))


plot_confusion_matrix(rf_cm, labels_sorted, "Random Forest - Confusion Matrix (Test Set)")
plot_confusion_matrix(gb_cm, labels_sorted, "Gradient Boosting - Confusion Matrix (Test Set)")


# At this point it should do the following:
# - which model performs better (accuracy / F1)
# - overfitting: if train_acc >> test_acc, model may be overfitting
# - Compare RF vs GB in terms of training time (efficiency)

# ---------------------------------------
#  FEATURE IMPORTANCE & INTERPRETATION
# ---------------------------------------
# Ensemble tree-based models (RF and GB) have a built-in notion of feature importance:
# - How much each feature reduces impurity across trees.
#  want to:
# - Extract feature importances
# - Map them back to human-readable feature names (after OneHotEncoding)
# - Plot the top features
# - Use them for insights/recommendations in the report.

# First, the fitted preprocessor and model from the pipeline.
rf_preprocessor = best_rf.named_steps['preprocess']
rf_model        = best_rf.named_steps['model']

gb_preprocessor = best_gb.named_steps['preprocess']
gb_model        = best_gb.named_steps['model']

# Obtain the feature names:
# - For the categorical part: use OneHotEncoder's get_feature_names_out
# - For the numeric part: it's just the original numeric column names
ohe = rf_preprocessor.named_transformers_['cat']
ohe_feature_names = list(ohe.get_feature_names_out(categorical_cols))
all_feature_names = ohe_feature_names + numeric_cols



rf_importances = pd.DataFrame({
    'feature': all_feature_names,
    'importance': rf_model.feature_importances_
}).sort_values(by='importance', ascending=False)

gb_importances = pd.DataFrame({
    'feature': all_feature_names,
    'importance': gb_model.feature_importances_
}).sort_values(by='importance', ascending=False)


print("\nTop 15 Random Forest features by importance:")
display(rf_importances.head(15))

# Plot top N most important features for RF
top_n = 15
plt.figure(figsize=(8, 6))
sns.barplot(data=rf_importances.head(top_n),
            x='importance', y='feature')
plt.title(f"Random Forest - Top {top_n} Feature Importances")
plt.tight_layout()
plt.show()



print("\nTop 15 Gradient Boostinmportance:")
display(gb_importances.head(15))

plt.figure(figsize=(8, 6))
sns.barplot(data=gb_importances.head(top_n),
            x='importance', y='feature')
plt.title(f"Gradient Boosting - Top {top_n} Feature Importances")
plt.tight_layout()
plt.show()
Exists: True
Is file: True
Readable: True
Fi'srthe st 5 rows of the dataset:
duration protocol_type service flag src_bytes dst_bytes land wrong_fragment urgent hot ... dst_host_srv_count dst_host_same_srv_rate dst_host_diff_srv_rate dst_host_same_src_port_rate dst_host_srv_diff_host_rate dst_host_serror_rate dst_host_srv_serror_rate dst_host_rerror_rate dst_host_srv_rerror_rate labels
0 5 tcp smtp SF 2429 475 0 0 0 0 ... 57 0.11 0.02 0.01 0.04 0.0 0.0 0.87 0.0 normal
1 0 udp domain_u SF 45 134 0 0 0 0 ... 155 0.80 0.02 0.01 0.00 0.0 0.0 0.00 0.0 normal
2 0 udp domain_u SF 45 80 0 0 0 0 ... 255 1.00 0.00 0.01 0.00 0.0 0.0 0.00 0.0 normal
3 1979 udp other SF 145 105 0 0 0 0 ... 2 0.01 0.84 1.00 0.00 0.0 0.0 0.00 0.0 normal
4 14462 tcp other RSTR 1 0 0 0 0 0 ... 2 0.01 0.68 1.00 0.00 0.0 0.0 1.00 1.0 portsweep

5 rows × 42 columns

Dataset shape (rows, columns): (22544, 42)

Column names:
['duration', 'protocol_type', 'service', 'flag', 'src_bytes', 'dst_bytes', 'land', 'wrong_fragment', 'urgent', 'hot', 'num_failed_logins', 'logged_in', 'num_compromised', 'root_shell', 'su_attempted', 'num_root', 'num_file_creations', 'num_shells', 'num_access_files', 'num_outbound_cmds', 'is_host_login', 'is_guest_login', 'count', 'srv_count', 'serror_rate', 'srv_serror_rate', 'rerror_rate', 'srv_rerror_rate', 'same_srv_rate', 'diff_srv_rate', 'srv_diff_host_rate', 'dst_host_count', 'dst_host_srv_count', 'dst_host_same_srv_rate', 'dst_host_diff_srv_rate', 'dst_host_same_src_port_rate', 'dst_host_srv_diff_host_rate', 'dst_host_serror_rate', 'dst_host_srv_serror_rate', 'dst_host_rerror_rate', 'dst_host_srv_rerror_rate', 'labels']

Info This about's the dataset (data tyHopfully theren-null counts):
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 22544 entries, 0 to 22543
Data columns (total 42 columns):
 #   Column                       Non-Null Count  Dtype  
---  ------                       --------------  -----  
 0   duration                     22544 non-null  int64  
 1   protocol_type                22544 non-null  object 
 2   service                      22544 non-null  object 
 3   flag                         22544 non-null  object 
 4   src_bytes                    22544 non-null  int64  
 5   dst_bytes                    22544 non-null  int64  
 6   land                         22544 non-null  int64  
 7   wrong_fragment               22544 non-null  int64  
 8   urgent                       22544 non-null  int64  
 9   hot                          22544 non-null  int64  
 10  num_failed_logins            22544 non-null  int64  
 11  logged_in                    22544 non-null  int64  
 12  num_compromised              22544 non-null  int64  
 13  root_shell                   22544 non-null  int64  
 14  su_attempted                 22544 non-null  int64  
 15  num_root                     22544 non-null  int64  
 16  num_file_creations           22544 non-null  int64  
 17  num_shells                   22544 non-null  int64  
 18  num_access_files             22544 non-null  int64  
 19  num_outbound_cmds            22544 non-null  int64  
 20  is_host_login                22544 non-null  int64  
 21  is_guest_login               22544 non-null  int64  
 22  count                        22544 non-null  int64  
 23  srv_count                    22544 non-null  int64  
 24  serror_rate                  22544 non-null  float64
 25  srv_serror_rate              22544 non-null  float64
 26  rerror_rate                  22544 non-null  float64
 27  srv_rerror_rate              22544 non-null  float64
 28  same_srv_rate                22544 non-null  float64
 29  diff_srv_rate                22544 non-null  float64
 30  srv_diff_host_rate           22544 non-null  float64
 31  dst_host_count               22544 non-null  int64  
 32  dst_host_srv_count           22544 non-null  int64  
 33  dst_host_same_srv_rate       22544 non-null  float64
 34  dst_host_diff_srv_rate       22544 non-null  float64
 35  dst_host_same_src_port_rate  22544 non-null  float64
 36  dst_host_srv_diff_host_rate  22544 non-null  float64
 37  dst_host_serror_rate         22544 non-null  float64
 38  dst_host_srv_serror_rate     22544 non-null  float64
 39  dst_host_rerror_rate         22544 non-null  float64
 40  dst_host_srv_rerror_rate     22544 non-null  float64
 41  labels                       22544 non-null  object 
dtypes: float64(15), int64(23), object(4)
memory usage: 7.2+ MB
None

Class diAn stributin (value counts for 'labels'):
labels
normal             11245
neptune             6654
satan                698
smurf                540
ipsweep              479
guess_passwd         411
portsweep            371
mscan                310
warezmaster          279
apache2              228
back                 227
processtable         211
nmap                 206
warezclient          107
teardrop             104
snmpguess             99
mailbomb              94
saint                 93
snmpgetattack         43
httptunnel            41
pod                   35
buffer_overflow       17
multihop               9
ps                     9
sendmail               8
xlock                  4
loadmodule             4
rootkit                4
xterm                  3
land                   2
imap                   2
ftp_write              2
named                  2
phf                    1
perl                   1
xsnoop                 1
Name: count, dtype: int64
No description has been provided for this image
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[1], line 104
    100 y = df['labels']
    102 # Perform an 80/20 train-test split, stratified by the class labels
    103 # so each split keeps approximately the same class proportion.
--> 104 X_train, X_test, y_train, y_test = train_test_split(
    105     X, y,
    106     test_size=0.2,
    107     stratify=y,
    108     random_state=RANDOM_STATE
    109 )
    111 print("\nTrain set shape:", X_train.shape, "Test set shape:", X_test.shape)
    113 # ---------------------the ------------------
    114 #  IDENTIFY CATEGORICAL AND NUMERIC FEATURES
    115 # ---------------------------------------
   (...)
    123 # - OneHotEncoder to categorical features
    124 # - StandardScaler to numeric features

File ~\anaconda3\Lib\site-packages\sklearn\utils\_param_validation.py:213, in validate_params.<locals>.decorator.<locals>.wrapper(*args, **kwargs)
    207 try:
    208     with config_context(
    209         skip_parameter_validation=(
    210             prefer_skip_nested_validation or global_skip_validation
    211         )
    212     ):
--> 213         return func(*args, **kwargs)
    214 except InvalidParameterError as e:
    215     # When the function is just a wrapper around an estimator, we allow
    216     # the function to delegate validation to the estimator, but we replace
    217     # the name of the estimator by the name of the function in the error
    218     # message to avoid confusion.
    219     msg = re.sub(
    220         r"parameter of \w+ must be",
    221         f"parameter of {func.__qualname__} must be",
    222         str(e),
    223     )

File ~\anaconda3\Lib\site-packages\sklearn\model_selection\_split.py:2801, in train_test_split(test_size, train_size, random_state, shuffle, stratify, *arrays)
   2797         CVClass = ShuffleSplit
   2799     cv = CVClass(test_size=n_test, train_size=n_train, random_state=random_state)
-> 2801     train, test = next(cv.split(X=arrays[0], y=stratify))
   2803 train, test = ensure_common_namespace_device(arrays[0], train, test)
   2805 return list(
   2806     chain.from_iterable(
   2807         (_safe_indexing(a, train), _safe_indexing(a, test)) for a in arrays
   2808     )
   2809 )

File ~\anaconda3\Lib\site-packages\sklearn\model_selection\_split.py:1843, in BaseShuffleSplit.split(self, X, y, groups)
   1813 """Generate indices to split data into training and test set.
   1814 
   1815 Parameters
   (...)
   1840 to an integer.
   1841 """
   1842 X, y, groups = indexable(X, y, groups)
-> 1843 for train, test in self._iter_indices(X, y, groups):
   1844     yield train, test

File ~\anaconda3\Lib\site-packages\sklearn\model_selection\_split.py:2247, in StratifiedShuffleSplit._iter_indices(self, X, y, groups)
   2245 class_counts = np.bincount(y_indices)
   2246 if np.min(class_counts) < 2:
-> 2247     raise ValueError(
   2248         "The least populated class in y has only 1"
   2249         " member, which is too few. The minimum"
   2250         " number of groups for any class cannot"
   2251         " be less than 2."
   2252     )
   2254 if n_train < n_classes:
   2255     raise ValueError(
   2256         "The train_size = %d should be greater or "
   2257         "equal to the number of classes = %d" % (n_train, n_classes)
   2258     )

ValueError: The least populated class in y has only 1 member, which is too few. The minimum number of groups for any class cannot be less than 2.
In [ ]:
 
In [ ]: