Files
labs/Lab-6/Lab-6-Solution/01_NSL_KDD_Flow_Audit_Solution.ipynb
T

23 KiB

Module 6 — Lab 1: Auditing NSL-KDD Flow Records

Topics: network flows, fixed split, labels, attack groups, leakage

Solution version: Run the cells in order, compare the outputs with your work, and read the suggested answers.

Learning objectives

After completing this lab, you will be able to:

  • distinguish network-flow features from targets and audit fields;
  • preserve the predefined NSL-KDD train/test split;
  • describe binary labels and the DoS, Probe, R2L, and U2R groups;
  • identify attack labels that appear only in the test set;
  • document imbalance, duplicates, and benchmark limitations.
In [1]:
from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

TRAIN_PATH = Path("data/nsl_kdd_train.csv")
TEST_PATH = Path("data/nsl_kdd_test.csv")

missing_files = [str(path) for path in (TRAIN_PATH, TEST_PATH) if not path.exists()]
if missing_files:
    raise FileNotFoundError(
        "Missing prepared NSL-KDD files: " + ", ".join(missing_files) +
        ". Follow data/README.md and run: python data/prepare_dataset.py"
    )

TARGET_COLUMNS = ["attack_label", "attack_group", "binary_label", "difficulty"]
CATEGORICAL_FEATURES = ["protocol_type", "service", "flag"]

1. Load and validate the fixed split

In [2]:
train_df = pd.read_csv(TRAIN_PATH, low_memory=False)
test_df = pd.read_csv(TEST_PATH, low_memory=False)
feature_cols = [column for column in train_df.columns if column not in TARGET_COLUMNS]
numeric_features = [column for column in feature_cols if column not in CATEGORICAL_FEATURES]

assert list(train_df.columns) == list(test_df.columns)
print("Train shape:", train_df.shape)
print("Test shape:", test_df.shape)
print("Model features:", len(feature_cols))
display(train_df.head())
Train shape: (125973, 45)
Test shape: (22544, 45)
Model features: 41
duration protocol_type service flag src_bytes dst_bytes land wrong_fragment urgent hot ... 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 attack_label attack_group binary_label difficulty
0 0 tcp ftp_data sf 491 0 0 0 0 0 ... 0.17 0.00 0.00 0.00 0.05 0.00 normal Normal 0 20
1 0 udp other sf 146 0 0 0 0 0 ... 0.88 0.00 0.00 0.00 0.00 0.00 normal Normal 0 15
2 0 tcp private s0 0 0 0 0 0 0 ... 0.00 0.00 1.00 1.00 0.00 0.00 neptune DoS 1 19
3 0 tcp http sf 232 8153 0 0 0 0 ... 0.03 0.04 0.03 0.01 0.00 0.01 normal Normal 0 21
4 0 tcp http sf 199 420 0 0 0 0 ... 0.00 0.00 0.00 0.00 0.00 0.00 normal Normal 0 21

5 rows × 45 columns

2. Audit classes, groups, and unseen attack labels

In [3]:
train_binary = train_df["binary_label"].value_counts().sort_index()
test_binary = test_df["binary_label"].value_counts().sort_index()
train_groups = train_df["attack_group"].value_counts()
test_groups = test_df["attack_group"].value_counts()

train_attack_labels = set(train_df.loc[train_df["binary_label"] == 1, "attack_label"])
test_attack_labels = set(test_df.loc[test_df["binary_label"] == 1, "attack_label"])
unseen_test_attacks = sorted(test_attack_labels - train_attack_labels)

display(pd.DataFrame({"train": train_binary, "test": test_binary}).fillna(0).astype(int))
display(pd.DataFrame({"train": train_groups, "test": test_groups}).fillna(0).astype(int))
print("Attack labels seen only in test:", unseen_test_attacks)
train test
binary_label
0 67343 9711
1 58630 12833
train test
attack_group
DoS 45927 7460
Normal 67343 9711
Probe 11656 2421
R2L 995 2885
U2R 52 67
Attack labels seen only in test: ['apache2', 'httptunnel', 'mailbomb', 'mscan', 'named', 'processtable', 'ps', 'saint', 'sendmail', 'snmpgetattack', 'snmpguess', 'sqlattack', 'udpstorm', 'worm', 'xlock', 'xsnoop', 'xterm']

3. Audit quality and leakage risks

In [4]:
quality_summary = pd.DataFrame({
    "train_missing": train_df[feature_cols].isna().sum(),
    "test_missing": test_df[feature_cols].isna().sum(),
    "train_unique": train_df[feature_cols].nunique(dropna=True),
    "test_unique": test_df[feature_cols].nunique(dropna=True),
})
duplicate_counts = {
    "train_exact_duplicates": int(train_df.duplicated().sum()),
    "test_exact_duplicates": int(test_df.duplicated().sum()),
}
forbidden_model_columns = sorted(set(TARGET_COLUMNS) & set(train_df.columns))

print(duplicate_counts)
print("Columns excluded from modeling:", forbidden_model_columns)
display(quality_summary.sort_values("train_unique").head(10))
{'train_exact_duplicates': 0, 'test_exact_duplicates': 0}
Columns excluded from modeling: ['attack_group', 'attack_label', 'binary_label', 'difficulty']
train_missing test_missing train_unique test_unique
num_outbound_cmds 0 0 1 1
is_host_login 0 0 2 2
is_guest_login 0 0 2 2
land 0 0 2 2
logged_in 0 0 2 2
root_shell 0 0 2 2
protocol_type 0 0 3 3
wrong_fragment 0 0 3 3
num_shells 0 0 3 4
su_attempted 0 0 3 3

Benchmark note

NSL-KDD is useful for learning a reproducible IDS workflow. Its fixed test set includes attacks that are absent from training, but it remains a legacy benchmark and does not represent modern production traffic.

Self-check

In [5]:
assert len(feature_cols) == 41
assert set(CATEGORICAL_FEATURES).issubset(feature_cols)
assert set(train_df["binary_label"].unique()) == {0, 1}
assert set(test_df["attack_group"].unique()).issubset({"Normal", "DoS", "Probe", "R2L", "U2R"})
assert len(unseen_test_attacks) > 0
assert set(feature_cols).isdisjoint(TARGET_COLUMNS)
assert quality_summary.shape[0] == 41
print("✓ Lab 1 completed")
✓ Lab 1 completed

Suggested answers

  1. Why keep the fixed split? It preserves the benchmark design and exposes attack labels that training never saw.
  2. Why exclude difficulty? It was derived from previous classifiers and is not a network measurement available to a real detector.
  3. Why inspect group counts? Binary metrics can hide poor detection of rare R2L or U2R records.