DilanjanaSDK's picture
Upload folder using huggingface_hub
c76fc30 verified
Raw
History Blame Contribute Delete
15.9 kB
"""
evaluate.py
===========
Evaluation suite for the LSTM-Autoencoder anomaly detector.
Benchmarks against three baselines:
1. Isolation Forest (statistical, no sequence awareness)
2. Random Forest (supervised, context-blind)
3. WAF simulation (pattern matching, signature-based)
Metrics reported:
- Accuracy, Precision, Recall, F1-Score
- False Positive Rate (FPR)
- Inference latency (ms per session)
- Throughput (sessions per second)
Author : K.A.D.S.D. Kandanaarachchi (2020/ICT/19)
Project: Detecting Anomalous REST API Traffic - IT4216
"""
import argparse
import json
import logging
import time
from pathlib import Path
import numpy as np
import torch
from sklearn.ensemble import IsolationForest, RandomForestClassifier
from sklearn.metrics import (
accuracy_score,
confusion_matrix,
f1_score,
precision_score,
recall_score,
)
from torch.utils.data import DataLoader, TensorDataset
from model import (
build_model_cicids2018,
build_model_csic2010,
build_model_unsw,
)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
datefmt="%H:%M:%S",
)
log = logging.getLogger(__name__)
def compute_metrics(y_true: np.ndarray, y_pred: np.ndarray, model_name: str) -> dict:
"""
Compute and log all evaluation metrics.
Returns a dict of results for saving.
"""
acc = accuracy_score(y_true, y_pred)
prec = precision_score(y_true, y_pred, zero_division=0)
rec = recall_score(y_true, y_pred, zero_division=0)
f1 = f1_score(y_true, y_pred, zero_division=0)
cm = confusion_matrix(y_true, y_pred)
tn, fp, fn, tp = cm.ravel()
fpr = fp / (fp + tn) if (fp + tn) > 0 else 0.0
log.info("── %s ──────────────────────────", model_name)
log.info(" Accuracy : %.4f", acc)
log.info(" Precision : %.4f", prec)
log.info(" Recall : %.4f", rec)
log.info(" F1-Score : %.4f", f1)
log.info(" FPR : %.4f", fpr)
log.info(" TP=%d FP=%d TN=%d FN=%d", tp, fp, tn, fn)
return {
"model": model_name,
"accuracy": round(acc, 4),
"precision": round(prec, 4),
"recall": round(rec, 4),
"f1": round(f1, 4),
"fpr": round(fpr, 4),
"tp": int(tp),
"fp": int(fp),
"tn": int(tn),
"fn": int(fn),
}
def measure_latency(fn, data, n_runs: int = 100) -> tuple[float, float]:
"""
Measure average inference latency and throughput.
Returns (latency_ms_per_session, sessions_per_second)
"""
# Warmup
for _ in range(5):
fn(data[:32])
times = []
for _ in range(n_runs):
start = time.perf_counter()
fn(data[:32])
times.append(time.perf_counter() - start)
avg_ms = np.mean(times) * 1000 / 32
throughput = 32 / np.mean(times)
return round(avg_ms, 4), round(throughput, 1)
def evaluate_lstm(
model,
X_test: np.ndarray,
y_test: np.ndarray,
threshold: float,
dataset: str,
device: str = "cpu",
) -> tuple[dict, float, float]:
"""
Run the LSTM-Autoencoder on the test set.
Flag sessions where reconstruction error > threshold.
"""
model.eval()
model = model.to(device)
if dataset == "csic2010":
tensor = torch.tensor(X_test, dtype=torch.long)
else:
X_test = np.nan_to_num(X_test, nan=0.0, posinf=0.0, neginf=0.0)
tensor = torch.tensor(X_test, dtype=torch.float32)
loader = DataLoader(
TensorDataset(tensor),
batch_size=512,
shuffle=False,
)
all_errors = []
with torch.no_grad():
for (batch,) in loader:
batch = batch.to(device)
errors = model.reconstruction_error(batch)
all_errors.extend(errors.cpu().numpy())
all_errors = np.array(all_errors)
y_pred = (all_errors > threshold).astype(int)
metrics = compute_metrics(y_test, y_pred, "LSTM-Autoencoder")
# Latency measurement
def infer(x):
with torch.no_grad():
if dataset == "csic2010":
t = torch.tensor(x, dtype=torch.long).to(device)
else:
t = torch.tensor(x, dtype=torch.float32).to(device)
return model.reconstruction_error(t)
latency, throughput = measure_latency(infer, X_test)
log.info(" Latency : %.4f ms/session", latency)
log.info(" Throughput: %.1f sessions/sec", throughput)
metrics["latency_ms"] = latency
metrics["throughput"] = throughput
metrics["threshold"] = threshold
metrics["error_mean"] = round(float(all_errors.mean()), 6)
metrics["error_std"] = round(float(all_errors.std()), 6)
return metrics, all_errors
def save_errors(errors: np.ndarray, y_test: np.ndarray, dataset: str, res_dir: Path) -> None:
"""
Save raw per-sample reconstruction errors and their true labels to disk.
This is what lets visualise.py plot the REAL error distribution
(Figures 2 and 7) instead of simulating one from mean_error/std_error.
Call this right after evaluate_lstm() — errors and y_test are
already aligned since evaluate_lstm uses shuffle=False.
"""
res_dir = Path(res_dir)
res_dir.mkdir(parents=True, exist_ok=True)
np.save(res_dir / f"errors_{dataset}.npy", errors)
np.save(res_dir / f"errors_labels_{dataset}.npy", y_test)
log.info("Saved raw errors → %s", res_dir / f"errors_{dataset}.npy")
def evaluate_isolation_forest(
X_train: np.ndarray,
X_test: np.ndarray,
y_test: np.ndarray,
dataset: str,
) -> dict:
"""
Isolation Forest baseline.
Treats each session as a flat feature vector — no sequence awareness.
This is the 'lightweight but blind' baseline from proposal.
"""
log.info("Training Isolation Forest...")
# Flatten sessions: (n, window, features) → (n, window*features)
if dataset == "csic2010":
# For token data use float conversion
X_tr_flat = X_train.astype(np.float32).reshape(len(X_train), -1)
X_te_flat = X_test.astype(np.float32).reshape(len(X_test), -1)
else:
X_train = np.nan_to_num(X_train, nan=0.0, posinf=0.0, neginf=0.0)
X_test = np.nan_to_num(X_test, nan=0.0, posinf=0.0, neginf=0.0)
X_tr_flat = X_train.reshape(len(X_train), -1)
X_te_flat = X_test.reshape(len(X_test), -1)
# Subsample training data for speed (IF doesn't need all 1.6M rows)
max_train = min(50_000, len(X_tr_flat))
idx = np.random.choice(len(X_tr_flat), max_train, replace=False)
clf = IsolationForest(
n_estimators=100,
contamination=0.05,
random_state=42,
n_jobs=-1,
)
clf.fit(X_tr_flat[idx])
# IF returns -1 for anomaly, 1 for normal — convert to 0/1
raw_pred = clf.predict(X_te_flat)
y_pred = (raw_pred == -1).astype(int)
metrics = compute_metrics(y_test, y_pred, "Isolation Forest")
def infer(x):
xf = x.astype(np.float32).reshape(len(x), -1)
return clf.predict(xf)
latency, throughput = measure_latency(infer, X_test)
log.info(" Latency : %.4f ms/session", latency)
log.info(" Throughput: %.1f sessions/sec", throughput)
metrics["latency_ms"] = latency
metrics["throughput"] = throughput
return metrics
def evaluate_random_forest(
X_train: np.ndarray,
y_train: np.ndarray,
X_test: np.ndarray,
y_test: np.ndarray,
dataset: str,
) -> dict:
"""
Random Forest baseline — supervised, context-blind.
Given labels during training (unlike our unsupervised model).
This represents the best-case supervised approach.
"""
log.info("Training Random Forest...")
if dataset == "csic2010":
X_tr_flat = X_train.astype(np.float32).reshape(len(X_train), -1)
X_te_flat = X_test.astype(np.float32).reshape(len(X_test), -1)
else:
X_train = np.nan_to_num(X_train, nan=0.0, posinf=0.0, neginf=0.0)
X_test = np.nan_to_num(X_test, nan=0.0, posinf=0.0, neginf=0.0)
X_tr_flat = X_train.reshape(len(X_train), -1)
X_te_flat = X_test.reshape(len(X_test), -1)
# Subsample for speed
max_train = min(50_000, len(X_tr_flat))
idx = np.random.choice(len(X_tr_flat), max_train, replace=False)
y_sub = y_train[idx] if len(y_train) > max_train else y_train
clf = RandomForestClassifier(
n_estimators=100,
random_state=42,
n_jobs=-1,
)
clf.fit(X_tr_flat[idx], y_sub)
y_pred = clf.predict(X_te_flat)
metrics = compute_metrics(y_test, y_pred, "Random Forest")
def infer(x):
xf = x.astype(np.float32).reshape(len(x), -1)
return clf.predict(xf)
latency, throughput = measure_latency(infer, X_test)
log.info(" Latency : %.4f ms/session", latency)
log.info(" Throughput: %.1f sessions/sec", throughput)
metrics["latency_ms"] = latency
metrics["throughput"] = throughput
return metrics
def evaluate_waf(
X_test: np.ndarray,
y_test: np.ndarray,
dataset: str,
data_dir: Path,
) -> dict:
"""
WAF simulation baseline — signature/pattern matching only.
For CSIC 2010: checks if any token in session is UNK (proxy for suspicious/unseen URL pattern)
For flow datasets: flags sessions where Dst Port is in the known attack port list (very basic rule).
This demonstrates why WAFs alone are insufficient.*
"""
log.info("Running WAF simulation...")
if dataset == "csic2010":
# UNK token (index 1) = URL pattern not seen in normal training
# This simulates a WAF that knows normal URL patterns
y_pred = (X_test == 1).any(axis=1).astype(int)
else:
# For flow data: flag if any packet in session has
# known suspicious port (very simplified WAF rule)
SUSPICIOUS_PORTS = {21, 22, 23, 25, 53, 3306, 3389, 4444, 8080, 8443}
# First feature in our set is Dst Port (index 0 after scaling)
# Use raw patterns since WAF doesn't use ML
# Simplified: flag sessions with extreme first-feature values
# (representing high port numbers after scaling)
port_vals = X_test[:, :, 0] # Dst Port column
y_pred = (np.abs(port_vals) > 2.0).any(axis=1).astype(int)
metrics = compute_metrics(y_test, y_pred, "WAF Simulation")
def infer(x):
if dataset == "csic2010":
return (x == 1).any(axis=1).astype(int)
else:
return (np.abs(x[:, :, 0]) > 2.0).any(axis=1).astype(int)
latency, throughput = measure_latency(infer, X_test)
log.info(" Latency : %.4f ms/session", latency)
log.info(" Throughput: %.1f sessions/sec", throughput)
metrics["latency_ms"] = latency
metrics["throughput"] = throughput
return metrics
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
"--dataset", required=True, choices=["csic2010", "cicids2018", "unsw"]
)
parser.add_argument(
"--skip_baselines",
action="store_true",
help="Only evaluate LSTM model, skip baselines",
)
parser.add_argument(
"--window",
type=int,
default=5,
help="Sliding window size (must match training)",
)
args = parser.parse_args()
device = "cuda" if torch.cuda.is_available() else "cpu"
data_dir = Path("data/processed")
mdl_dir = Path("models")
res_dir = Path("results")
res_dir.mkdir(exist_ok=True)
run_id = f"{args.dataset}_w{args.window}"
log.info("=" * 55)
log.info("EVALUATION — %s (window=%d)", args.dataset.upper(), args.window)
log.info("=" * 55)
# Load test data — filenames are window-suffixed by preprocessing.py
X_test = np.load(data_dir / f"X_test_{run_id}.npy")
y_test = np.load(data_dir / f"y_test_{run_id}.npy")
X_train = np.load(data_dir / f"X_train_{run_id}.npy")
log.info("X_test shape: %s", X_test.shape)
log.info(
"y_test shape: %s (normal=%d attack=%d)",
y_test.shape,
(y_test == 0).sum(),
(y_test == 1).sum(),
)
# Build labels for RF (needs some attack labels)
# Use a portion of test attacks combined with train normals
n_normal = min(len(X_train), 50_000)
n_attack = min((y_test == 1).sum(), 10_000)
X_rf_normal = X_train[:n_normal]
y_rf_normal = np.zeros(n_normal, dtype=int)
X_rf_attack = X_test[y_test == 1][:n_attack]
y_rf_attack = np.ones(n_attack, dtype=int)
X_rf_train = np.concatenate([X_rf_normal, X_rf_attack], axis=0)
y_rf_train = np.concatenate([y_rf_normal, y_rf_attack], axis=0)
# Load threshold — cross-check the window it was trained with
thresh_path = mdl_dir / f"threshold_{run_id}.json"
thresh_data = json.load(open(thresh_path))
threshold = thresh_data["threshold"]
saved_window = thresh_data.get("window")
if saved_window is not None and saved_window != args.window:
raise ValueError(
f"Window mismatch: {thresh_path.name} was trained with "
f"window={saved_window}, but --window={args.window} was passed. "
f"Pass --window {saved_window} to match the trained model."
)
log.info("Threshold: %.6f", threshold)
# Build and load model
if args.dataset == "csic2010":
checkpoint = torch.load(
mdl_dir / f"best_{run_id}.pt", map_location=device
)
embed_weight = checkpoint["embedding.weight"]
vocab_size = embed_weight.shape[0]
model = build_model_csic2010(vocab_size=vocab_size, seq_len=args.window)
elif args.dataset == "cicids2018":
model = build_model_cicids2018(n_features=X_test.shape[2], seq_len=args.window)
else:
model = build_model_unsw(n_features=X_test.shape[2], seq_len=args.window)
model.load_state_dict(
torch.load(mdl_dir / f"best_{run_id}.pt", map_location=device)
)
# Run evaluations
all_results = []
# LSTM-Autoencoder
lstm_metrics, errors = evaluate_lstm(
model, X_test, y_test, threshold, args.dataset, device
)
all_results.append(lstm_metrics)
# Save raw per-sample errors + true labels so visualise.py can plot
# the REAL error distribution instead of simulating one.
save_errors(errors, y_test, run_id, res_dir)
if not args.skip_baselines:
# Isolation Forest
if_metrics = evaluate_isolation_forest(X_train, X_test, y_test, args.dataset)
all_results.append(if_metrics)
# Random Forest
rf_metrics = evaluate_random_forest(
X_rf_train, y_rf_train, X_test, y_test, args.dataset
)
all_results.append(rf_metrics)
# WAF simulation
waf_metrics = evaluate_waf(X_test, y_test, args.dataset, data_dir)
all_results.append(waf_metrics)
# Print comparison table
log.info("")
log.info("=" * 55)
log.info("COMPARISON TABLE — %s", args.dataset.upper())
log.info("=" * 55)
log.info(
"%-22s %6s %6s %6s %6s %8s", "Model", "Prec", "Rec", "F1", "FPR", "Lat(ms)"
)
log.info("-" * 55)
for r in all_results:
log.info(
"%-22s %6.4f %6.4f %6.4f %6.4f %8.4f",
r["model"],
r["precision"],
r["recall"],
r["f1"],
r["fpr"],
r["latency_ms"],
)
# Save results
out = {
"dataset": args.dataset,
"window": args.window,
"results": all_results,
"error_distribution": {
"mean": lstm_metrics["error_mean"],
"std": lstm_metrics["error_std"],
"threshold": threshold,
},
}
out_path = res_dir / f"evaluation_{run_id}.json"
with open(out_path, "w") as f:
json.dump(out, f, indent=2)
log.info("Results saved → %s", out_path)
if __name__ == "__main__":
main()