Intrusion Detection Experiment (DDoS Detection)¶

This notebook explores machine learning-based intrusion detection using the CICIDS2017 dataset.

Workflow¶

  1. Introduction
  2. Data loading
  3. Data preprocessing
  4. Model training
  5. Evaluation
  6. Feature importance analysis
  7. Visualization
  8. Error analysis

Dataset¶

CICIDS2017 (Canadian Institute for Cybersecurity)

A flow-based network traffic dataset containing benign and attack traffic.
Each record represents a network flow with 79 features.

Dataset size: 225,745 flows
Features: 79
Classes: BENIGN, DDoS

Result¶

Accuracy: 1.00 (FP=0, FN=4)

The model achieved near-perfect accuracy, suggesting that DDoS traffic in this dataset has highly distinctive characteristics compared to benign traffic.

In [48]:
# ==============================
# IMPORTS
# ==============================

import os
import pandas as pd
import numpy as np

from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, confusion_matrix

from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA

import matplotlib.pyplot as plt
import seaborn as sns
In [28]:
# ==============================
# CONFIG
# ==============================

SEED = 42
import os
DATA_PATH = "Friday-WorkingHours-Afternoon-DDos.pcap_ISCX.csv"

1. Data loading¶

We load the CICIDS2017 dataset and erase spaces in columns.

In [29]:
# ==============================
# LOAD DATA
# ==============================

df = pd.read_csv(DATA_PATH)
df.columns = df.columns.str.strip()

df.head()
Out[29]:
Destination Port Flow Duration Total Fwd Packets Total Backward Packets Total Length of Fwd Packets Total Length of Bwd Packets Fwd Packet Length Max Fwd Packet Length Min Fwd Packet Length Mean Fwd Packet Length Std ... min_seg_size_forward Active Mean Active Std Active Max Active Min Idle Mean Idle Std Idle Max Idle Min Label
0 54865 3 2 0 12 0 6 6 6.0 0.0 ... 20 0.0 0.0 0 0 0.0 0.0 0 0 BENIGN
1 55054 109 1 1 6 6 6 6 6.0 0.0 ... 20 0.0 0.0 0 0 0.0 0.0 0 0 BENIGN
2 55055 52 1 1 6 6 6 6 6.0 0.0 ... 20 0.0 0.0 0 0 0.0 0.0 0 0 BENIGN
3 46236 34 1 1 6 6 6 6 6.0 0.0 ... 20 0.0 0.0 0 0 0.0 0.0 0 0 BENIGN
4 54863 3 2 0 12 0 6 6 6.0 0.0 ... 20 0.0 0.0 0 0 0.0 0.0 0 0 BENIGN

5 rows × 79 columns

In [30]:
df["Label"].value_counts()
Out[30]:
Label
DDoS      128027
BENIGN     97718
Name: count, dtype: int64

DDoS = Distributed Denial of Service
BENIGN = benign traffic

2. Data preprocessing¶

Change DDoS and BENIGN into numeric value so the machine can understand:
BENIGN → 0
DDoS → 1

In [31]:
# ==============================
# PREPROCESSING
# ==============================

df["Label"] = df["Label"].str.strip()

df["Label"] = df["Label"].map({
    "BENIGN":0,
    "DDoS":1
})

Infinity cleaning

In [32]:
import numpy as np

df.replace([np.inf, -np.inf], np.nan, inplace=True)
df.dropna(inplace=True)

Separate Labels and features

In [33]:
X = df.drop("Label", axis=1)
y = df["Label"]

3. Random Forest model training¶

In [34]:
# ==============================
# TRAIN MODEL
# ==============================

X_train, X_test, y_train, y_test = train_test_split(
    X, y,
    test_size=0.2,
    random_state=SEED
)

model = RandomForestClassifier(
    n_estimators=200,
    random_state=SEED,
    n_jobs=-1
)

model.fit(X_train, y_train)
Out[34]:
RandomForestClassifier(n_estimators=200, n_jobs=-1, random_state=42)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Parameters
n_estimators n_estimators: int, default=100

The number of trees in the forest.

.. versionchanged:: 0.22
The default value of ``n_estimators`` changed from 10 to 100
in 0.22.
200
criterion criterion: {"gini", "entropy", "log_loss"}, default="gini"

The function to measure the quality of a split. Supported criteria are
"gini" for the Gini impurity and "log_loss" and "entropy" both for the
Shannon information gain, see :ref:`tree_mathematical_formulation`.
Note: This parameter is tree-specific.
'gini'
max_depth max_depth: int, default=None

The maximum depth of the tree. If None, then nodes are expanded until
all leaves are pure or until all leaves contain less than
min_samples_split samples.
None
min_samples_split min_samples_split: int or float, default=2

The minimum number of samples required to split an internal node:

- If int, then consider `min_samples_split` as the minimum number.
- If float, then `min_samples_split` is a fraction and
`ceil(min_samples_split * n_samples)` are the minimum
number of samples for each split.

.. versionchanged:: 0.18
Added float values for fractions.
2
min_samples_leaf min_samples_leaf: int or float, default=1

The minimum number of samples required to be at a leaf node.
A split point at any depth will only be considered if it leaves at
least ``min_samples_leaf`` training samples in each of the left and
right branches. This may have the effect of smoothing the model,
especially in regression.

- If int, then consider `min_samples_leaf` as the minimum number.
- If float, then `min_samples_leaf` is a fraction and
`ceil(min_samples_leaf * n_samples)` are the minimum
number of samples for each node.

.. versionchanged:: 0.18
Added float values for fractions.
1
min_weight_fraction_leaf min_weight_fraction_leaf: float, default=0.0

The minimum weighted fraction of the sum total of weights (of all
the input samples) required to be at a leaf node. Samples have
equal weight when sample_weight is not provided.
0.0
max_features max_features: {"sqrt", "log2", None}, int or float, default="sqrt"

The number of features to consider when looking for the best split:

- If int, then consider `max_features` features at each split.
- If float, then `max_features` is a fraction and
`max(1, int(max_features * n_features_in_))` features are considered at each
split.
- If "sqrt", then `max_features=sqrt(n_features)`.
- If "log2", then `max_features=log2(n_features)`.
- If None, then `max_features=n_features`.

.. versionchanged:: 1.1
The default of `max_features` changed from `"auto"` to `"sqrt"`.

Note: the search for a split does not stop until at least one
valid partition of the node samples is found, even if it requires to
effectively inspect more than ``max_features`` features.
'sqrt'
max_leaf_nodes max_leaf_nodes: int, default=None

Grow trees with ``max_leaf_nodes`` in best-first fashion.
Best nodes are defined as relative reduction in impurity.
If None then unlimited number of leaf nodes.
None
min_impurity_decrease min_impurity_decrease: float, default=0.0

A node will be split if this split induces a decrease of the impurity
greater than or equal to this value.

The weighted impurity decrease equation is the following::

N_t / N * (impurity - N_t_R / N_t * right_impurity
- N_t_L / N_t * left_impurity)

where ``N`` is the total number of samples, ``N_t`` is the number of
samples at the current node, ``N_t_L`` is the number of samples in the
left child, and ``N_t_R`` is the number of samples in the right child.

``N``, ``N_t``, ``N_t_R`` and ``N_t_L`` all refer to the weighted sum,
if ``sample_weight`` is passed.

.. versionadded:: 0.19
0.0
bootstrap bootstrap: bool, default=True

Whether bootstrap samples are used when building trees. If False, the
whole dataset is used to build each tree.
True
oob_score oob_score: bool or callable, default=False

Whether to use out-of-bag samples to estimate the generalization score.
By default, :func:`~sklearn.metrics.accuracy_score` is used.
Provide a callable with signature `metric(y_true, y_pred)` to use a
custom metric. Only available if `bootstrap=True`.

For an illustration of out-of-bag (OOB) error estimation, see the example
:ref:`sphx_glr_auto_examples_ensemble_plot_ensemble_oob.py`.
False
n_jobs n_jobs: int, default=None

The number of jobs to run in parallel. :meth:`fit`, :meth:`predict`,
:meth:`decision_path` and :meth:`apply` are all parallelized over the
trees. ``None`` means 1 unless in a :obj:`joblib.parallel_backend`
context. ``-1`` means using all processors. See :term:`Glossary
` for more details.
-1
random_state random_state: int, RandomState instance or None, default=None

Controls both the randomness of the bootstrapping of the samples used
when building trees (if ``bootstrap=True``) and the sampling of the
features to consider when looking for the best split at each node
(if ``max_features < n_features``).
See :term:`Glossary ` for details.
42
verbose verbose: int, default=0

Controls the verbosity when fitting and predicting.
0
warm_start warm_start: bool, default=False

When set to ``True``, reuse the solution of the previous call to fit
and add more estimators to the ensemble, otherwise, just fit a whole
new forest. See :term:`Glossary ` and
:ref:`tree_ensemble_warm_start` for details.
False
class_weight class_weight: {"balanced", "balanced_subsample"}, dict or list of dicts, default=None

Weights associated with classes in the form ``{class_label: weight}``.
If not given, all classes are supposed to have weight one. For
multi-output problems, a list of dicts can be provided in the same
order as the columns of y.

Note that for multioutput (including multilabel) weights should be
defined for each class of every column in its own dict. For example,
for four-class multilabel classification weights should be
[{0: 1, 1: 1}, {0: 1, 1: 5}, {0: 1, 1: 1}, {0: 1, 1: 1}] instead of
[{1:1}, {2:5}, {3:1}, {4:1}].

The "balanced" mode uses the values of y to automatically adjust
weights inversely proportional to class frequencies in the input data
as ``n_samples / (n_classes * np.bincount(y))``

The "balanced_subsample" mode is the same as "balanced" except that
weights are computed based on the bootstrap sample for every tree
grown.

For multi-output, the weights of each column of y will be multiplied.

Note that these weights will be multiplied with sample_weight (passed
through the fit method) if sample_weight is specified.
None
ccp_alpha ccp_alpha: non-negative float, default=0.0

Complexity parameter used for Minimal Cost-Complexity Pruning. The
subtree with the largest cost complexity that is smaller than
``ccp_alpha`` will be chosen. By default, no pruning is performed. See
:ref:`minimal_cost_complexity_pruning` for details. See
:ref:`sphx_glr_auto_examples_tree_plot_cost_complexity_pruning.py`
for an example of such pruning.

.. versionadded:: 0.22
0.0
max_samples max_samples: int or float, default=None

If bootstrap is True, the number of samples to draw from X
to train each base estimator.

- If None (default), then draw `X.shape[0]` samples.
- If int, then draw `max_samples` samples.
- If float, then draw `max(round(n_samples * max_samples), 1)` samples. Thus,
`max_samples` should be in the interval `(0.0, 1.0]`.

.. versionadded:: 0.22
None
monotonic_cst monotonic_cst: array-like of int of shape (n_features), default=None

Indicates the monotonicity constraint to enforce on each feature.
- 1: monotonic increase
- 0: no constraint
- -1: monotonic decrease

If monotonic_cst is None, no constraints are applied.

Monotonicity constraints are not supported for:
- multiclass classifications (i.e. when `n_classes > 2`),
- multioutput classifications (i.e. when `n_outputs_ > 1`),
- classifications trained on data with missing values.

The constraints hold over the probability of the positive class.

Read more in the :ref:`User Guide `.

.. versionadded:: 1.4
None

4. Evaluation of detection performance¶

In [53]:
# ==============================
# EVALUATION
# ==============================

pred = model.predict(X_test)

print(classification_report(y_test, pred))
print(confusion_matrix(y_test, pred))

from sklearn.metrics import roc_auc_score

prob = model.predict_proba(X_test)[:,1]
auc = roc_auc_score(y_test, prob)
print("ROC-AUC:", auc)
              precision    recall  f1-score   support

           0       1.00      1.00      1.00     19419
           1       1.00      1.00      1.00     25724

    accuracy                           1.00     45143
   macro avg       1.00      1.00      1.00     45143
weighted avg       1.00      1.00      1.00     45143

[[19419     0]
 [    4 25720]]
ROC-AUC: 0.9999999619645781

Note:
Near-perfect accuracy can occur on CICIDS2017 DDoS because attack traffic has highly distinctive flow-level patterns;
results may not generalize to stealthier/low-rate attacks.

The ROC-AUC score provides an additional evaluation metric for classification performance. It measures how well the model distinguishes between benign and attack traffic.

5. Feature importance analysis¶

Feature importance indicates which network flow features contribute most to the model's decision.

In [36]:
# ==============================
# FEATURE IMPORTANCE
# ==============================

importances = pd.Series(
    model.feature_importances_,
    index=X.columns
).sort_values(ascending=False)

importances.head(15)
Out[36]:
Avg Fwd Segment Size           0.079347
Fwd Packet Length Mean         0.075830
Fwd Packet Length Max          0.075561
Init_Win_bytes_forward         0.061795
act_data_pkt_fwd               0.051683
Subflow Fwd Bytes              0.049415
Bwd Packet Length Min          0.044783
Total Length of Fwd Packets    0.040985
Subflow Fwd Packets            0.039623
Fwd Header Length.1            0.035786
Fwd IAT Std                    0.035205
Fwd IAT Total                  0.035072
Fwd Packet Length Std          0.033320
Destination Port               0.031995
Fwd Header Length              0.029577
dtype: float64
In [37]:
importances.head(15).plot(kind="barh")
plt.gca().invert_yaxis()
plt.title("Top Features for DDoS Detection")
plt.show()
No description has been provided for this image

6. PCA visualization of traffic patterns¶

To better understand the structure of the traffic data, I project the high-dimensional features into two dimensions using PCA.

In [38]:
# ==============================
# PCA VISUALIZATION
# ==============================

from sklearn.preprocessing import StandardScaler

scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)

2D compression

In [39]:
from sklearn.decomposition import PCA

pca = PCA(n_components=2)
X_pca = pca.fit_transform(X_scaled)

Dataframing

In [40]:
pca_df = pd.DataFrame(X_pca, columns=["PC1", "PC2"])
pca_df["Label"] = y.values

Output Graph

In [41]:
plt.figure(figsize=(8,6))

sns.scatterplot(
    data=pca_df,
    x="PC1",
    y="PC2",
    hue="Label",
    alpha=0.5
)

plt.title("PCA Visualization of Network Traffic")
plt.show()
No description has been provided for this image

Sampling

In [42]:
benign = pca_df[pca_df["Label"]==0].sample(2000)
ddos = pca_df[pca_df["Label"]==1].sample(2000)

plot_df = pd.concat([benign, ddos])

sns.scatterplot(
    data=plot_df,
    x="PC1",
    y="PC2",
    hue="Label"
)
Out[42]:
<Axes: xlabel='PC1', ylabel='PC2'>
No description has been provided for this image
In [47]:
pca.explained_variance_ratio_, pca.explained_variance_ratio_.sum()
Out[47]:
(array([0.21412279, 0.1471762 ]), np.float64(0.3612989973920781))

PC1 = 0.214
PC2 = 0.147
SUM = 0.361

Although this does not represent the full structure of the data, it is sufficient to visualize general traffic separation; enabling a 2D visualization of network traffic patterns.

7. Analysis of misclassified attacks¶

To understand the limitations of the model, we inspect the misclassified attack samples (false negatives).

In [44]:
# ==============================
# ERROR ANALYSIS
# ==============================

pred_series = pd.Series(pred, index=X_test.index)

fn_index = X_test[(y_test == 1) & (pred_series == 0)].index

fn_cases = df.loc[fn_index]
fn_cases
Out[44]:
Destination Port Flow Duration Total Fwd Packets Total Backward Packets Total Length of Fwd Packets Total Length of Bwd Packets Fwd Packet Length Max Fwd Packet Length Min Fwd Packet Length Mean Fwd Packet Length Std ... min_seg_size_forward Active Mean Active Std Active Max Active Min Idle Mean Idle Std Idle Max Idle Min Label
101887 80 134 1 1 6 6 6 6 6.0 0.0 ... 20 0.0 0.0 0 0 0.0 0.0 0 0 1
82257 80 6 1 1 6 6 6 6 6.0 0.0 ... 20 0.0 0.0 0 0 0.0 0.0 0 0 1
45376 80 1663726 2 0 12 0 6 6 6.0 0.0 ... 20 0.0 0.0 0 0 0.0 0.0 0 0 1
196996 80 1974 3 0 18 0 6 6 6.0 0.0 ... 20 0.0 0.0 0 0 0.0 0.0 0 0 1

4 rows × 79 columns

In [45]:
fn_cases.mean()
df[df["Label"]==1].mean()
Out[45]:
Destination Port               8.122740e+01
Flow Duration                  1.695586e+07
Total Fwd Packets              4.472494e+00
Total Backward Packets         3.255856e+00
Total Length of Fwd Packets    3.190900e+01
                                   ...     
Idle Mean                      1.198550e+07
Idle Std                       4.481584e+06
Idle Max                       1.515447e+07
Idle Min                       8.816552e+06
Label                          1.000000e+00
Length: 79, dtype: float64
In [46]:
attack_mean = df[df["Label"]==1].mean()

comparison = pd.DataFrame({
    "missed_attack": fn_cases.mean(),
    "normal_attack": attack_mean
})

comparison
Out[46]:
missed_attack normal_attack
Destination Port 80.00 8.122740e+01
Flow Duration 416460.00 1.695586e+07
Total Fwd Packets 1.75 4.472494e+00
Total Backward Packets 0.50 3.255856e+00
Total Length of Fwd Packets 10.50 3.190900e+01
... ... ...
Idle Mean 0.00 1.198550e+07
Idle Std 0.00 4.481584e+06
Idle Max 0.00 1.515447e+07
Idle Min 0.00 8.816552e+06
Label 1.00 1.000000e+00

79 rows × 2 columns

Result: Accuracy 1.00, FN=4, FP=0 (Random Forest, CICIDS2017 Friday DDoS).

The misclassified attacks show significantly lower packet counts and shorter flow duration compared to typical DDoS traffic. This suggests that low-intensity attacks may appear similar to benign traffic, making them harder to detect.