Files
labs/Lab-6/Lab-6-Solution/04_OneClass_IsolationForest_Solution.ipynb
T

9.0 KiB

Module 6 — Lab 4: One-Class SVM and Isolation Forest

Topics: normal-only learning, anomaly scores, validation thresholds, PR-AUC, runtime

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:

  • fit two normal-only novelty detectors;
  • standardize features for One-Class SVM;
  • derive a common anomaly-score convention;
  • compare detectors with the same normal-validation alert budget;
  • explain performance and scalability trade-offs.
In [ ]:
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"]


from sklearn.ensemble import IsolationForest
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.svm import OneClassSVM


from sklearn.metrics import (
    accuracy_score, average_precision_score, confusion_matrix,
    f1_score, precision_score, recall_score,
)


def security_metrics(y_true, y_pred):
    tn, fp, fn, tp = confusion_matrix(y_true, y_pred, labels=[0, 1]).ravel()
    return {
        "accuracy": accuracy_score(y_true, y_pred),
        "attack_precision": precision_score(y_true, y_pred, zero_division=0),
        "attack_recall": recall_score(y_true, y_pred, zero_division=0),
        "attack_f1": f1_score(y_true, y_pred, zero_division=0),
        "false_positives": int(fp),
        "false_negatives": int(fn),
        "false_positive_rate": fp / (fp + tn) if fp + tn else 0.0,
        "alerts_per_10000": 10000 * (fp + tp) / len(y_true),
    }
In [ ]:
train_df = pd.read_csv(TRAIN_PATH, low_memory=False)
test_df = pd.read_csv(TEST_PATH, low_memory=False)
anomaly_features = [
    "duration", "src_bytes", "dst_bytes", "wrong_fragment", "hot",
    "num_failed_logins", "logged_in", "count", "srv_count",
    "serror_rate", "rerror_rate", "same_srv_rate", "diff_srv_rate",
    "dst_host_count", "dst_host_srv_count", "dst_host_same_srv_rate",
    "dst_host_diff_srv_rate", "dst_host_serror_rate", "dst_host_rerror_rate",
]
normal = train_df.loc[train_df["binary_label"] == 0, anomaly_features]
normal_fit, normal_valid = train_test_split(normal, test_size=0.20, random_state=42)
normal_fit = normal_fit.sample(min(5000, len(normal_fit)), random_state=42)
X_test = test_df[anomaly_features]
y_test = test_df["binary_label"].astype(int)
print("Normal fit/validation/test:", len(normal_fit), len(normal_valid), len(X_test))

1. Fit the two detectors

In [ ]:
detectors = {
    "One-Class SVM": Pipeline([
        ("scaler", StandardScaler()),
        ("model", OneClassSVM(kernel="rbf", gamma="scale", nu=0.02)),
    ]),
    "Isolation Forest": Pipeline([
        ("scaler", StandardScaler()),
        ("model", IsolationForest(
            n_estimators=200, max_samples="auto", contamination="auto",
            random_state=42, n_jobs=-1,
        )),
    ]),
}

for detector in detectors.values():
    detector.fit(normal_fit)
print("Fitted detectors:", list(detectors))

2. Apply a common validation-threshold policy

In [ ]:
comparison_rows = []
detector_outputs = {}

for name, detector in detectors.items():
    validation_scores = -detector.decision_function(normal_valid)
    test_scores = -detector.decision_function(X_test)
    threshold = float(np.quantile(validation_scores, 0.99))
    predictions = (test_scores > threshold).astype(int)
    metrics = security_metrics(y_test, predictions)
    metrics["pr_auc"] = average_precision_score(y_test, test_scores)
    comparison_rows.append({"detector": name, "threshold": threshold, **metrics})
    detector_outputs[name] = {
        "validation_scores": validation_scores,
        "test_scores": test_scores,
        "predictions": predictions,
    }

detector_comparison = pd.DataFrame(comparison_rows).set_index("detector")
display(detector_comparison.round(4))

3. Compare attack-group recall

In [ ]:
group_rows = []
for name, output in detector_outputs.items():
    for group in ["DoS", "Probe", "R2L", "U2R"]:
        mask = test_df["attack_group"].eq(group).to_numpy()
        group_rows.append({
            "detector": name,
            "attack_group": group,
            "support": int(mask.sum()),
            "recall": float(output["predictions"][mask].mean()) if mask.any() else np.nan,
        })
group_recall = pd.DataFrame(group_rows).pivot(
    index="attack_group", columns="detector", values="recall"
)
display(group_recall.round(4))

Self-check

In [ ]:
assert set(detectors) == {"One-Class SVM", "Isolation Forest"}
assert len(normal_fit) <= 5000
assert all(len(output["test_scores"]) == len(test_df) for output in detector_outputs.values())
assert detector_comparison.shape[0] == 2
assert set(group_recall.index) == {"DoS", "Probe", "R2L", "U2R"}
assert detector_comparison["false_positive_rate"].between(0, 1).all()
print("✓ Lab 4 completed")

Suggested answers

  1. Why cap One-Class SVM training? The RBF implementation scales poorly as the number of training samples grows.
  2. Why negate decision_function? Both models then follow one convention: larger values mean more anomalous.
  3. Why can rare-group recall differ? Different attacks occupy different regions of feature space, and some resemble normal traffic in the selected features.