-
Notifications
You must be signed in to change notification settings - Fork 1
Isolation Forest Model Training on CIC‐IDS2017 Dataset(Tuesday Working Hours)
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.
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.
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:
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).
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?
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.
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.
Decision: Train on BENIGN only. This is the architecturally correct approach for unsupervised anomaly detection in a security context.
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)
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)
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.
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% |
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.
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
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
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.
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.
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:
-
1for points it considers normal -
-1for points it considers anomalous BlackTrace uses: -
0for BENIGN (normal) -
1for 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→ our1(anomaly = attack) - sklearn's
+1→ our0(normal = benign)
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
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
| 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 |
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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
| 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.
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 withfailed_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.