Skip to content

Isolation Forest Model Training on CIC‐IDS2017 Dataset(Tuesday Working Hours)

Penmatsa Tanoj Pavan Surya Varma edited this page Jul 1, 2026 · 1 revision

Purpose of this document: This wiki documents the complete Isolation Forest training pipeline for BlackTrace — every architectural decision, every parameter choice, every result, and every conclusion drawn from those results. It explains the algorithm from first principles, documents what worked, what failed, and why. Written to be understood by a newcomer and detailed enough to serve as a technical reference.

1. The core intuition of Isolation Forest

Isolation Forest is an unsupervised anomaly detection algorithm. It does not learn from labels. It does not know what "FTP-Patator" means. It learns one thing only: what normal data looks like. Anything that deviates significantly from normal is flagged as an anomaly. The name comes from its core insight anomalies are easier to isolate than normal points. Normal points cluster together. Anomalous points are isolated. The algorithm exploits this by measuring how few random splits it takes to isolate each point.

2. Why Isolation Forest for BlackTrace

BlackTrace is a Security Operations Centre (SOC) assistant. In a real SOC, new attack types appear constantly. A purely supervised model can only detect attacks it has seen before in training. If a new attack variant emerges, it is invisible to the model until the model is retrained with new labelled data. Isolation Forest provides a complementary capability: zero-day anomaly detection. If an attack generates network flows that look statistically unusual compared to the normal traffic baseline even if that attack type has never been seen before, the Isolation Forest will flag it. This is why BlackTrace uses a two-model architecture:

mermaid-diagram (1)

The Isolation Forest is the first line of defence. The Random Forest is the analyst that names the threat. Together they cover both known attacks (supervised classification) and potentially unknown anomalies (unsupervised detection).

3. Architecture Decision — What Data to Train On

Before writing any code, one fundamental architectural decision must be made:

Should Isolation Forest train on BENIGN traffic only, or on the full mixed dataset?

Option A — Train on full mixed data

Train on all 356,727 rows including both BENIGN and attack flows.

Problem: Isolation Forest would learn that attack traffic is part of the "normal" distribution. It trains to model whatever data it receives. If attack rows are present in training, the model will partially learn their feature patterns as normal. At inference time, similar attack flows would score as normal and be missed.

Option B — Train on BENIGN traffic only(Correct Approach)

Train only on the 345,659 BENIGN rows. The model learns exclusively what legitimate network traffic looks like.

Why this is correct: This is the standard anomaly detection approach in security engineering. You define the normal baseline from known-good data. Everything that deviates from that baseline becomes a candidate anomaly. The model has never seen attack traffic, so attack traffic if it is statistically different from normal will score as anomalous.

mermaid-diagram (2)

Decision: Train on BENIGN only. This is the architecturally correct approach for unsupervised anomaly detection in a security context.

5. Step-by-Step Implementation


Step 1 — Load Processed Dataset

Loads the output of the preprocessing pipeline. At this point the dataset is clean with no infinities, no NaN values, labels encoded, is_zero_duration flag present.

Shape entering the training script: (445,909, 82)


Step 2 — Feature and Label Separation

NON_FEATURE_COLS = ["Label", "y_binary", "y_multiclass"]
 
X = df.drop(columns=NON_FEATURE_COLS)
y_binary = df["y_binary"]
 
leaked = set(NON_FEATURE_COLS) & set(X.columns)
assert len(leaked) == 0, f"Labels leaked: {leaked}"

Three columns must never enter the feature matrix:

Column Why excluded
Label Raw string label — not a numeric feature
y_binary The answer the model is supposed to find — including it is cheating
y_multiclass Same reason as y_binary

is_zero_duration is kept in X. It is an engineered feature, not a label.

The assertion is a hard guard against label leakage. Label leakage is when information about the answer gets included in the input features, producing artificially high metrics that collapse completely on real data.

Feature matrix shape: (445,909, 79)


Step 3 — Train/Test Split

X_train_full, X_test, y_train_full, y_test = train_test_split(
    X, y_binary,
    test_size=0.2,
    random_state=42,
    stratify=y_binary
)

Why stratify=y_binary:

Without stratification, the random split might produce a test set with a different attack ratio than the training set. With 3.1% attacks in the full dataset, a non-stratified split could accidentally put most attacks in training and almost none in test — making evaluation meaningless.

Stratification guarantees the same 3.1% / 96.9% ratio in both sets.

Why random_state=42:

Every script that recreates this split — the training script, the evaluate script — must use the same seed. Different seeds produce different splits. If the evaluate script uses a different seed, it will test the model on rows that were part of training, making all reported metrics invalid.

Split results:

Set Rows Attack ratio
Training 356,727 0.0310
Test 89,182 0.0310

Ratios are identical — stratification worked correctly.


Step 4 — BENIGN-Only Training Subset

X_train_benign = X_train_full[y_train_full == 0]

From the 356,727 training rows, only the 345,659 BENIGN rows are kept for actual model training. The 11,068 attack rows in the training split are set aside — they are never shown to the Isolation Forest.

Subset Rows Percentage
X_train_full 356,727 100%
X_train_benign 345,659 96.9%
Attack rows excluded 11,068 3.1%

Step 5 — StandardScaler

scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train_benign)
X_test_scaled = scaler.transform(X_test)

StandardScaler transforms each feature to have mean=0 and standard deviation=1.

Why scaling matters for Isolation Forest:

Isolation Forest selects a random feature and a random split value between that feature's minimum and maximum. Features with large value ranges are selected more often and have more influence over every split, while features with small ranges are rarely selected and have almost no influence.

In the BlackTrace dataset, the value ranges are extremely unequal:

Feature Approximate range
Flow Bytes/s 0 to 145,000,000
Flow Duration 0 to several million
is_zero_duration 0 or 1 only
Total Fwd Packets 0 to a few thousand

Without scaling, Flow Bytes/s would dominate almost every random split. The carefully engineered is_zero_duration flag — which has a range of exactly 1 — would be nearly invisible to the model. Scaling puts all 79 features on equal footing.

Critical rule — fit on training data only:

scaler.fit_transform(X_train_benign) computes the mean and standard deviation from BENIGN training data only, then applies the transformation.

scaler.transform(X_test) applies the same transformation to the test set using the means and standard deviations computed from training — it does not recompute them.

If you fit the scaler on the full dataset or on the test set, information about the test set's distribution leaks into the transformation. This is data leakage and produces metrics that are over-optimistic and not reproducible in production.

The scaler is saved alongside the model so the FastAPI inference endpoint applies the exact same transformation at prediction time. A model trained on scaled data evaluated on unscaled data produces garbage predictions.


Step 6 — The Contamination Parameter

attack_ratio = float(y_train_full.mean())  # = 0.0310
 
model = IsolationForest(
    contamination=attack_ratio,
    ...
)

contamination is the single most important hyperparameter in Isolation Forest. It tells the model what fraction of the data to treat as anomalies when setting its decision threshold.

What happens with the wrong contamination:

flowchart TD
    A["Default contamination = 0.1\n(sklearn assumes 10% anomalies)"] --> B
    A2["Actual attack ratio = 0.031\n(3.1% anomalies in reality)"] --> B
 
    B["Model sets threshold\nassuming 10% of data is anomalous"] --> C
 
    C["Only 3.1% of data is actually anomalous\nModel's threshold is too aggressive"] --> D
 
    D["Most attack flows score above threshold\nModel labels them as normal\nRecall = 0.00"]
 
    style D fill:#742a2a,color:#fed7d7
Loading

What happens with the correct contamination:

flowchart TD
    A["contamination = 0.031\n(matches actual attack ratio)"] --> B
 
    B["Model sets threshold\nassuming 3.1% of data is anomalous"] --> C
 
    C["Decision boundary aligns\nwith actual data distribution"] --> D
 
    D["Anomalous flows more likely\nto score below threshold\nand be flagged correctly"]
 
    style D fill:#276749,color:#c6f6d5
Loading

The contamination is computed dynamically from the training split rather than hardcoded:

attack_ratio = float(y_train_full.mean())

This ensures that if the dataset changes — different files, different splits — the contamination automatically adjusts to match the real ratio rather than relying on a hardcoded number that might become stale.


Step 7 — Training

model = IsolationForest(
    n_estimators=100,
    contamination=attack_ratio,
    random_state=42,
    n_jobs=-1
)
model.fit(X_train_scaled)

Parameter explanations:

Parameter Value Reason
n_estimators 100 Number of isolation trees to build. More trees = more stable scores. 100 is the standard starting point.
contamination 0.0310 Actual attack ratio from training split. Sets the decision threshold correctly.
random_state 42 Reproducibility. Same seed = same trees = same results every run.
n_jobs -1 Use all available CPU cores. Building 100 trees is parallelisable.

The model trains on X_train_scaled — 345,659 BENIGN rows, 79 features, all scaled.


Step 8 — Evaluation

raw_predictions = model.predict(X_test_scaled)
y_pred = (raw_predictions == -1).astype(int)

The sklearn convention conversion — this is critical:

sklearn's Isolation Forest returns:

  • 1 for points it considers normal
  • -1 for points it considers anomalous BlackTrace uses:
  • 0 for BENIGN (normal)
  • 1 for ATTACK (anomalous) Without the conversion (raw_predictions == -1).astype(int), the confusion matrix is completely inverted. BENIGN rows score as attacks and attack rows score as normal. This was the cause of a previous failed evaluation in this project.

The conversion maps:

  • sklearn's -1 → our 1 (anomaly = attack)
  • sklearn's +1 → our 0 (normal = benign)

Step 9 — Saving Artifacts

joblib.dump(model, MODEL_DIR / "isolation_forest.joblib")
joblib.dump(scaler, MODEL_DIR / "scaler.joblib")

Both the model and the scaler are saved together. They are inseparable — a model trained on scaled data must always receive scaled input. Saving the scaler alongside the model guarantees this contract is enforced at inference time.

Both files are gitignored. They are regenerated by running the training script. Their paths are:

detection_engine/models/isolation_forest.joblib
detection_engine/models/scaler.joblib

6. Results

Final classification report

              precision    recall  f1-score   support
 
      BENIGN       0.97      0.97      0.97     86415
      ATTACK       0.00      0.00      0.00      2767
 
    accuracy                           0.94     89182
   macro avg       0.48      0.48      0.48     89182
weighted avg       0.94      0.94      0.94     89182

Confusion matrix breakdown

Predicted BENIGN Predicted ATTACK
Actual BENIGN 83,722 2,693
Actual ATTACK 2,767 0
Metric Value Meaning
True Negatives 83,722 BENIGN correctly identified as normal
False Positives 2,693 Normal traffic wrongly flagged as attack
False Negatives 2,767 Attacks completely missed
True Positives 0 Attacks correctly detected
ATTACK Recall 0.00 Model detected zero attacks
BENIGN Precision 0.97 When it says BENIGN, it is right 97% of the time
Overall Accuracy 94% Misleading — achieved by predicting BENIGN for everything

7. Root Cause Analysis — Why Recall is Zero

The immediate answer

The Isolation Forest detected zero attacks. This is not a bug in the code. The implementation is correct — contamination is set properly, BENIGN-only training is applied, the sklearn convention is converted correctly. The zero recall is a fundamental limitation of the algorithm applied to this specific attack type.

The deep explanation

FTP-Patator and SSH-Patator are brute force credential attacks. Understanding why they defeat Isolation Forest requires understanding what these attacks actually look like at the network flow level.

What a brute force attack looks like per flow:

Each individual connection attempt in a brute force attack generates a network flow that looks like this:

  • Normal duration (a login attempt takes the same time as a real login)
  • Normal packet sizes (login protocol packets are the same size whether the credentials are real or not)
  • Normal byte counts (the server sends the same rejection message it would send to a wrong password)
  • Normal TCP flags (the connection follows the same handshake as legitimate traffic) The attack is only visible when you look at the pattern across hundreds or thousands of flows: same source IP, same destination port, repeated failed authentications in rapid succession.

What Isolation Forest sees:

flowchart LR
    A["Flow 1: Failed login\nDuration: 120ms\nBytes: 340\nPackets: 4"] --> B
 
    B["Isolation Forest\nevaluates this flow\nin isolation"] --> C
 
    C{Does this flow\nlook unusual?}
 
    C -->|"Compare to BENIGN baseline\nSame duration as normal logins\nSame packet size as normal logins\nSame byte count as normal logins"| D
 
    D["Scores as NORMAL\nreturns +1\ny_pred = 0"]
 
    style D fill:#742a2a,color:#fed7d7
Loading

Isolation Forest has no concept of "this is the 500th failed login from this IP in the last 60 seconds." It evaluates each row as an independent point in feature space. The individual flow is indistinguishable from a normal failed login attempt.

Why the 94% accuracy is meaningless

The model achieved 94% overall accuracy while detecting zero attacks. This is the classic trap of evaluating imbalanced classifiers with accuracy:

If a model predicts BENIGN for every single row:
  - It is correct for 86,415 BENIGN rows
  - It is wrong for 2,767 ATTACK rows
  - Accuracy = 86,415 / 89,182 = 96.9%
 
Our model predicts ATTACK for some rows (2,693 false positives):
  - Accuracy = (83,722 + 0) / 89,182 = 93.9%

The model with 94% accuracy is actually performing worse than a model that predicts BENIGN for everything, while still detecting zero attacks. Accuracy is the wrong metric for imbalanced security datasets. Recall for the attack class is the metric that matters.

Why this is not a failure of implementation

Three things were verified to rule out implementation errors:

1 — Contamination was set correctly. We used attack_ratio = 0.0310 not the default 0.1. A wrong contamination value was the cause of a previous failed run and was verified fixed.

2 — BENIGN-only training was applied correctly. The model trained on 345,659 BENIGN rows. Attack rows were explicitly excluded from training.

3 — sklearn convention was converted correctly. (raw_predictions == -1).astype(int) maps sklearn's anomaly flag to BlackTrace's label convention. The confusion matrix confirmed the predictions were in the correct direction — the model was predicting BENIGN, not mislabelled ATTACK.

The zero recall is caused by the nature of the attack type, not by implementation error.

Academic context

This limitation is documented in cybersecurity ML research. Studies on the CICIDS-2017 dataset consistently report that per-flow anomaly detectors struggle with brute force attacks because the per-flow statistics of credential stuffing attacks overlap heavily with normal authentication traffic. Papers addressing this limitation typically introduce time-window aggregation features (features that summarise the last N flows from the same source IP) before applying anomaly detection. That is a Phase 3+ enhancement for BlackTrace.


8. Final Conclusions

Conclusion 1 — The Isolation Forest implementation is correct

Every architectural and implementation decision is sound:

  • BENIGN-only training is the correct approach for unsupervised anomaly detection
  • Contamination set to actual attack ratio is the correct calibration
  • StandardScaler fit on training data only prevents data leakage
  • sklearn convention conversion is applied correctly The implementation can be verified, explained, and defended.

Conclusion 2 — Isolation Forest is the wrong algorithm for brute force detection on per-flow data

FTP-Patator and SSH-Patator attacks are statistically indistinguishable from normal traffic at the individual flow level. Isolation Forest evaluates each flow independently and cannot detect patterns that only emerge across multiple flows over time.

This is a known, documented limitation and is the reason the BlackTrace architecture includes a second model.

Conclusion 3 — The artifacts are production-ready

Both isolation_forest.joblib and scaler.joblib are saved and functional. The model can be loaded and used for inference. On datasets where attacks produce statistically unusual per-flow features — such as DoS attacks with extreme packet rates, or port scans with abnormal flag distributions — this Isolation Forest will produce meaningful anomaly scores.

Conclusion 4 — The architecture decision is validated

The zero recall result on brute force attacks validates the decision to use a two-model architecture. A system that relied solely on Isolation Forest would miss every FTP-Patator and SSH-Patator attack in this dataset. The Random Forest classifier in the next phase is designed specifically to address this gap.


9. What This Means for the BlackTrace Architecture

flowchart TD
    A["Incoming network flow"] --> B
 
    B["Isolation Forest\nAnomaly Score"] --> C{Score\nbelow threshold?}
 
    C -->|"Yes — looks anomalous\n(works well for:\nDoS, port scans,\nunknown attacks)"| D["Flag as suspicious\npass to Random Forest"]
 
    C -->|"No — looks normal\n(limitation:\nmisses brute force\nper-flow)"| E["Classify as BENIGN\nno alert raised"]
 
    D --> F["Random Forest\nClassifier"]
 
    F --> G["Identify attack type:\nFTP-Patator\nSSH-Patator\nDDoS\netc."]
 
    G --> H["Generate incident alert\nwith attack type,\nconfidence score,\nand SHAP explanation"]
 
    subgraph "Phase 2 — Detection Engine"
        B
        F
    end
 
    subgraph "Phase 3 — LLM Intelligence"
        H
    end
 
    style B fill:#2c5282,color:#bee3f8
    style F fill:#276749,color:#c6f6d5
    style H fill:#744210,color:#fefcbf
Loading

The complementary roles

Model Strength Limitation
Isolation Forest Detects unknown anomalies, high-volume attacks, statistical outliers Cannot detect brute force per-flow
Random Forest Precisely identifies FTP-Patator, SSH-Patator with high recall Only detects attack types seen in training

Neither model alone is sufficient. Together they provide coverage that neither achieves independently.

Future enhancement — time-window features

The Isolation Forest's limitation on brute force attacks can be partially addressed in a later phase by adding time-window aggregate features:

  • failed_logins_last_60s_from_src_ip — count of failed authentication flows from the same source IP in the last 60 seconds
  • unique_dest_ports_last_30s — number of distinct destination ports contacted by source IP in 30 seconds
  • avg_flow_duration_last_10_flows — moving average of flow duration for this source IP These features transform a per-flow problem into a session-level problem. With these features, a brute force attack would produce a flow with failed_logins_last_60s_from_src_ip = 500, which would be a massive outlier compared to normal traffic where this value is typically 0 or 1.

Clone this wiki locally