| """ |
| visualise.py |
| ============ |
| Fully data-driven thesis figure generator. |
| |
| Reads from (filenames are window-suffixed, e.g. "_w5", "_w7"): |
| models/history_{dataset}_w{window}.json β per-epoch training curves |
| models/threshold_{dataset}_w{window}.json β detection thresholds |
| results/evaluation_{dataset}_w{window}.json β model comparison metrics |
| results/active_learning_{dataset}_w{window}.json β AL iteration results |
| results/errors_{dataset}_w{window}.npy β real per-sample reconstruction errors |
| data/processed/y_test_{dataset}_w{window}.npy β label counts |
| |
| Which window to load per dataset is set via WINDOW_BY_DATASET below. |
| Change it and re-run to compare results across different window sizes |
| without re-running the whole pipeline. |
| |
| Dataset record counts (normal/attack totals) are fixed constants |
| from the original dataset papers β not model outputs. |
| |
| Usage |
| ----- |
| cd ~/api-anomaly-detection |
| python src/visualise.py |
| |
| Author : K.A.D.S.D. Kandanaarachchi (2020/ICT/19) |
| Project: Detecting Anomalous REST API Traffic β IT4216 |
| """ |
|
|
| import json |
| from pathlib import Path |
|
|
| import matplotlib.pyplot as plt |
| import numpy as np |
|
|
| |
| plt.rcParams.update( |
| { |
| "font.family": "DejaVu Sans", |
| "font.size": 17, |
| "axes.titlesize": 20, |
| "axes.titleweight": "bold", |
| "axes.labelsize": 17, |
| "xtick.labelsize": 15, |
| "ytick.labelsize": 15, |
| "legend.fontsize": 15, |
| "axes.spines.top": False, |
| "axes.spines.right": False, |
| "axes.grid": True, |
| "grid.alpha": 0.3, |
| "grid.linestyle": "--", |
| "figure.dpi": 150, |
| "savefig.dpi": 300, |
| "savefig.bbox": "tight", |
| "savefig.facecolor": "white", |
| } |
| ) |
|
|
| TEAL = "#028090" |
| NAVY = "#0D1B2A" |
| MINT = "#02C39A" |
| CORAL = "#D85A30" |
| AMBER = "#F59E0B" |
| PURPLE = "#7C3AED" |
| GRAY = "#94A3B8" |
|
|
| BASE = Path(__file__).parent.parent |
| RES_DIR = BASE / "results" |
| DATA_DIR = BASE / "data" / "processed" |
| MDL_DIR = BASE / "models" |
| FIG_DIR = RES_DIR / "figures" |
| FIG_DIR.mkdir(parents=True, exist_ok=True) |
|
|
| |
| DATASET_COUNTS = { |
| "csic2010": {"normal": 72000, "attack": 25065, "label": "CSIC 2010"}, |
| "cicids2018": { |
| "normal": 2096222, |
| "attack": 928, |
| "label": "CIC-IDS2018\n(used in this thesis)", |
| }, |
| "unsw": {"normal": 93000, "attack": 164673, "label": "UNSW-NB15"}, |
| } |
|
|
| |
| |
| |
| |
| |
| |
| _CICIDS2018_DAILY = { |
| |
| "02-14": (667_626, 193_360 + 187_589), |
| "02-15": (996_077, 41_508 + 10_990), |
| "02-16": (446_772, 461_912 + 139_890), |
| "02-20": (7_372_557, 576_191), |
| "02-21": (360_833, 686_012 + 1_730), |
| "02-22": (1_048_213, 249 + 79 + 34), |
| "02-23": (1_048_009, 362 + 151 + 53), |
| "02-28": (544_200, 68_871), |
| "03-01": (238_037, 93_063), |
| "03-02": (762_384, 286_191), |
| } |
| CICIDS2018_FULL_NORMAL = sum(n for n, a in _CICIDS2018_DAILY.values()) |
| CICIDS2018_FULL_ATTACK = sum(a for n, a in _CICIDS2018_DAILY.values()) |
| CICIDS2018_FULL_RELEASE_ROWS = CICIDS2018_FULL_NORMAL + CICIDS2018_FULL_ATTACK |
| CICIDS2018_USED_ROWS = ( |
| DATASET_COUNTS["cicids2018"]["normal"] + DATASET_COUNTS["cicids2018"]["attack"] |
| ) |
| CICIDS2018_USED_PCT = CICIDS2018_USED_ROWS / CICIDS2018_FULL_RELEASE_ROWS * 100 |
|
|
| |
| |
| |
| DATASET_COUNTS["cicids2018_full"] = { |
| "normal": CICIDS2018_FULL_NORMAL, |
| "attack": CICIDS2018_FULL_ATTACK, |
| "label": "CIC-IDS2018\n(full 10-day release)", |
| } |
|
|
| DATASET_COLORS = { |
| "csic2010": TEAL, |
| "cicids2018": AMBER, |
| "cicids2018_full": GRAY, |
| "unsw": PURPLE, |
| } |
|
|
| |
| |
| |
| |
| |
| |
| WINDOW_BY_DATASET = { |
| "csic2010": 5, |
| "cicids2018": 5, |
| "unsw": 5, |
| } |
|
|
|
|
| def _run_id(dataset: str) -> str: |
| """Build the window-suffixed filename tag for a dataset, e.g. 'csic2010_w5'.""" |
| return f"{dataset}_w{WINDOW_BY_DATASET[dataset]}" |
|
|
|
|
| def discover_windows(dataset: str) -> list[int]: |
| """ |
| Find every window size that has a threshold_{dataset}_w{N}.json on |
| disk, by scanning models/. Used by the window-comparison figure so |
| it doesn't need WINDOW_BY_DATASET edited by hand for every window |
| you've run β it just picks up whatever preprocessing/train/evaluate |
| have actually produced. |
| """ |
| pattern = f"threshold_{dataset}_w*.json" |
| windows = [] |
| for p in MDL_DIR.glob(pattern): |
| |
| stem = p.stem |
| tail = stem.rsplit("_w", 1)[-1] |
| if tail.isdigit(): |
| windows.append(int(tail)) |
| return sorted(windows) |
|
|
|
|
| def load_threshold_full_for(dataset: str, window: int) -> dict: |
| """Same as load_threshold_full but for an explicit window, not WINDOW_BY_DATASET.""" |
| path = MDL_DIR / f"threshold_{dataset}_w{window}.json" |
| if not path.exists(): |
| return {} |
| return json.load(open(path)) |
|
|
|
|
| def load_eval_for(dataset: str, window: int) -> dict: |
| """Same as load_eval but for an explicit window, not WINDOW_BY_DATASET.""" |
| path = RES_DIR / f"evaluation_{dataset}_w{window}.json" |
| if not path.exists(): |
| return {} |
| data = json.load(open(path)) |
| return {r["model"]: r for r in data["results"]} |
|
|
|
|
| def _grid_2x2(figsize=(14.5, 11.5), n_used=3): |
| """ |
| Create a 2x2 grid of axes for figures that previously used a single |
| wide row (1x3 for three-dataset panels, or 1x4 for four-panel |
| figures). Reflowing to 2 columns x 2 rows gives each panel roughly |
| double the linear size for the same total figure area, which is |
| the main lever for print legibility (font/marker/line sizes are |
| fixed in points, so a panel with twice the area renders everything |
| proportionally larger on the page). |
| |
| n_used=3: fills axes 0,1,2 (row-major) and hides axes[3] (blank |
| bottom-right cell) β for the common three-dataset case. |
| n_used=4: fills all four axes β for four-panel figures. |
| Returns (fig, axes_flat) where axes_flat has length 4 regardless, |
| so callers can always index axes_flat[0..n_used-1]. |
| """ |
| fig, axes = plt.subplots(2, 2, figsize=figsize) |
| axes_flat = axes.flatten() |
| if n_used < 4: |
| for ax in axes_flat[n_used:]: |
| ax.axis("off") |
| return fig, axes_flat |
|
|
|
|
| def _fig_suffix() -> str: |
| """ |
| Filename suffix for saved figures, reflecting WINDOW_BY_DATASET. |
| If all three datasets use the same window, use "_w{N}" (clean and |
| readable). If they differ (comparing mixed windows across |
| datasets), spell out each one so outputs never silently overwrite |
| a previous run with different settings. |
| """ |
| windows = set(WINDOW_BY_DATASET.values()) |
| if len(windows) == 1: |
| return f"_w{windows.pop()}" |
| return "_" + "-".join(f"{k}w{v}" for k, v in WINDOW_BY_DATASET.items()) |
|
|
|
|
| |
|
|
|
|
| def load_history(dataset: str) -> dict: |
| path = MDL_DIR / f"history_{_run_id(dataset)}.json" |
| if not path.exists(): |
| print(f" WARNING: {path.name} not found") |
| return {} |
| return json.load(open(path)) |
|
|
|
|
| def load_threshold(dataset: str) -> float | None: |
| path = MDL_DIR / f"threshold_{_run_id(dataset)}.json" |
| if not path.exists(): |
| return None |
| return json.load(open(path))["threshold"] |
|
|
|
|
| def load_threshold_full(dataset: str) -> dict: |
| path = MDL_DIR / f"threshold_{_run_id(dataset)}.json" |
| if not path.exists(): |
| return {} |
| return json.load(open(path)) |
|
|
|
|
| def load_eval(dataset: str) -> dict: |
| path = RES_DIR / f"evaluation_{_run_id(dataset)}.json" |
| if not path.exists(): |
| print(f" WARNING: {path.name} not found") |
| return {} |
| data = json.load(open(path)) |
| return {r["model"]: r for r in data["results"]} |
|
|
|
|
| def load_al(dataset: str) -> list: |
| path = RES_DIR / f"active_learning_{_run_id(dataset)}.json" |
| if not path.exists(): |
| return [] |
| return json.load(open(path))["history"] |
|
|
|
|
| def load_y_test(dataset: str) -> np.ndarray | None: |
| path = DATA_DIR / f"y_test_{_run_id(dataset)}.npy" |
| if not path.exists(): |
| return None |
| return np.load(path) |
|
|
|
|
| def load_real_errors(dataset: str) -> tuple[np.ndarray, np.ndarray] | None: |
| """ |
| Load real per-sample reconstruction errors + labels saved by |
| evaluate.py (evaluate_lstm + save_errors). Returns (errors, labels) |
| or None if evaluate.py hasn't been (re-)run since this feature was added. |
| """ |
| run_id = _run_id(dataset) |
| err_path = RES_DIR / f"errors_{run_id}.npy" |
| lbl_path = RES_DIR / f"errors_labels_{run_id}.npy" |
| if not (err_path.exists() and lbl_path.exists()): |
| return None |
| return np.load(err_path), np.load(lbl_path) |
|
|
|
|
| |
| |
| |
| def fig_learning_curves(): |
| datasets = [ |
| ("csic2010", "CSIC 2010", TEAL), |
| ("cicids2018", "CIC-IDS2018", AMBER), |
| ("unsw", "UNSW-NB15", PURPLE), |
| ] |
|
|
| fig, axes = _grid_2x2(n_used=3) |
|
|
| for ax, (key, label, color) in zip(axes, datasets): |
| h = load_history(key) |
| if not h: |
| ax.text( |
| 0.5, |
| 0.5, |
| "history not found", |
| ha="center", |
| va="center", |
| transform=ax.transAxes, |
| ) |
| ax.set_title(label) |
| continue |
|
|
| ep = h["epochs"] |
| train_loss = h["train_loss"] |
| val_loss = h["val_loss"] |
|
|
| ax.plot(ep, train_loss, color=color, lw=2, label="Train loss", zorder=3) |
| ax.plot( |
| ep, |
| val_loss, |
| color=color, |
| lw=2, |
| linestyle="--", |
| alpha=0.7, |
| label="Val loss", |
| zorder=3, |
| ) |
|
|
| |
| best_ep = ep[int(np.argmin(val_loss))] |
| ax.axvline( |
| best_ep, |
| color=CORAL, |
| linestyle=":", |
| lw=1.5, |
| label=f"Best val (ep {best_ep})", |
| ) |
|
|
| ax.set_title(label) |
| ax.set_xlabel("Epoch") |
| ax.set_ylabel("MSE Loss") |
| ax.legend(fontsize=14) |
| ax.set_xlim(1, max(ep)) |
|
|
| plt.tight_layout() |
| out = FIG_DIR / f"01_learning_curves{_fig_suffix()}.png" |
| plt.savefig(out) |
| plt.close() |
| print(f" Saved: {out.name}") |
|
|
|
|
| |
| |
| |
| |
| def fig_error_distributions(): |
| configs = [ |
| ("csic2010", "CSIC 2010", TEAL), |
| ("cicids2018", "CIC-IDS2018", AMBER), |
| ("unsw", "UNSW-NB15", PURPLE), |
| ] |
|
|
| fig, axes = _grid_2x2(n_used=3) |
|
|
| rng = np.random.default_rng(42) |
|
|
| for ax, (key, name, color) in zip(axes, configs): |
| threshold = load_threshold(key) |
| if threshold is None: |
| ax.text( |
| 0.5, 0.5, "threshold not found", |
| ha="center", va="center", transform=ax.transAxes, |
| ) |
| ax.set_title(name) |
| continue |
|
|
| real = load_real_errors(key) |
|
|
| if real is not None: |
| errors, labels = real |
| normal_errors = errors[labels == 0] |
| attack_errors = errors[labels == 1] |
| is_simulated = False |
| else: |
| |
| |
| |
| |
| thresh_data = load_threshold_full(key) |
| mean_err = thresh_data.get("mean_error", threshold * 0.3) |
| raw_std = thresh_data.get("std_error", threshold * 0.2) |
| std_err = min(raw_std, threshold * 1.5) |
|
|
| y = load_y_test(key) |
| n_normal = int((y == 0).sum()) if y is not None else 5000 |
| n_attack = int((y == 1).sum()) if y is not None else 2000 |
|
|
| normal_errors = np.abs(rng.normal(mean_err * 0.5, std_err * 0.3, min(n_normal, 5000))) |
| normal_errors = np.clip(normal_errors, 0, threshold * 0.95) |
| attack_errors = np.abs(rng.normal(threshold * 4, threshold * 2, min(n_attack, 2000))) |
| attack_errors = np.clip(attack_errors, threshold * 0.5, threshold * 15) |
| is_simulated = True |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| eps = max(threshold * 1e-3, 1e-12) |
| normal_clipped = np.clip(normal_errors, eps, None) |
| attack_clipped = np.clip(attack_errors, eps, None) |
| combined_max = max(normal_clipped.max(), attack_clipped.max(), threshold) * 1.1 |
| combined_min = min(normal_clipped.min(), attack_clipped.min(), threshold) * 0.9 |
| combined_min = max(combined_min, eps) |
| log_bins = np.logspace( |
| np.log10(combined_min), np.log10(combined_max), 60 |
| ) |
|
|
| ax.hist( |
| normal_clipped, bins=log_bins, alpha=0.65, color=color, |
| label="Normal", density=True, zorder=3, |
| ) |
| ax.hist( |
| attack_clipped, bins=log_bins, alpha=0.55, color=CORAL, |
| label="Attack", density=True, zorder=2, |
| ) |
| ax.axvline( |
| threshold, color=NAVY, lw=2, linestyle="-.", |
| label=f"Ο = {threshold:.5f}", zorder=4, |
| ) |
|
|
| ax.set_xscale("log") |
| |
| |
| |
| |
| |
| |
| |
| |
| ax.set_yscale("log") |
| ax.set_title(name) |
| ax.set_xlabel("Reconstruction Error (MSE, log scale)") |
| ax.set_ylabel("Density (log scale)") |
| ax.legend(fontsize=14) |
|
|
| if is_simulated: |
| ax.text( |
| 0.98, 0.98, "SIMULATED\n(re-run evaluate.py to plot real errors)", |
| transform=ax.transAxes, ha="right", va="top", |
| fontsize=12.5, color=CORAL, fontweight="bold", |
| bbox=dict(boxstyle="round", facecolor="white", edgecolor=CORAL, alpha=0.85), |
| ) |
|
|
| plt.tight_layout() |
| out = FIG_DIR / f"02_error_distributions{_fig_suffix()}.png" |
| plt.savefig(out) |
| plt.close() |
| print(f" Saved: {out.name}") |
|
|
|
|
| |
| |
| |
| def fig_model_comparison(): |
| ev = load_eval("csic2010") |
| if not ev: |
| print(" SKIP fig 3 β evaluation_csic2010.json not found") |
| return |
|
|
| model_order = [ |
| "LSTM-Autoencoder", |
| "Isolation Forest", |
| "Random Forest", |
| "WAF Simulation", |
| ] |
| short_labels = ["LSTM-AE", "Isolation\nForest", "Random\nForest", "WAF\nSim"] |
| colors = [TEAL, AMBER, CORAL, GRAY] |
|
|
| f1 = [ev[m]["f1"] for m in model_order] |
| recall = [ev[m]["recall"] for m in model_order] |
| fpr = [ev[m]["fpr"] for m in model_order] |
| latency = [ev[m]["latency_ms"] for m in model_order] |
|
|
| fig, axes = _grid_2x2(n_used=4) |
|
|
| metrics = [ |
| (axes[0], f1, "F1-Score", [0, 1.05]), |
| (axes[1], recall, "Recall", [0, 1.05]), |
| (axes[2], fpr, "False Positive Rate", [0, max(fpr) * 1.4]), |
| (axes[3], latency, "Latency (ms)", None), |
| ] |
|
|
| for ax, vals, label, ylim in metrics: |
| bars = ax.bar( |
| short_labels, |
| vals, |
| color=colors, |
| width=0.55, |
| edgecolor="white", |
| linewidth=1.5, |
| zorder=3, |
| ) |
| bars[0].set_edgecolor(NAVY) |
| bars[0].set_linewidth(2.5) |
| for bar, val in zip(bars, vals): |
| h = bar.get_height() |
| ax.text( |
| bar.get_x() + bar.get_width() / 2, |
| h + max(vals) * 0.02, |
| f"{val:.3f}", |
| ha="center", |
| va="bottom", |
| fontsize=14, |
| fontweight="bold", |
| ) |
| ax.set_title(label) |
| ax.set_ylabel(label) |
| if ylim: |
| ax.set_ylim(ylim) |
| ax.tick_params(axis="x", labelsize=13) |
|
|
| plt.tight_layout() |
| out = FIG_DIR / f"03_model_comparison_csic{_fig_suffix()}.png" |
| plt.savefig(out) |
| plt.close() |
| print(f" Saved: {out.name}") |
|
|
|
|
| |
| |
| |
| def fig_cross_dataset(): |
| keys = ["csic2010", "cicids2018", "unsw"] |
| labels = ["CSIC 2010", "CIC-IDS2018", "UNSW-NB15"] |
|
|
| names = [] |
| precision = [] |
| recall = [] |
| f1 = [] |
| fpr = [] |
|
|
| for key, label in zip(keys, labels): |
| ev = load_eval(key) |
| if not ev or "LSTM-Autoencoder" not in ev: |
| print(f" WARNING: LSTM-Autoencoder missing from {key} evaluation") |
| continue |
| r = ev["LSTM-Autoencoder"] |
| names.append(label) |
| precision.append(r["precision"]) |
| recall.append(r["recall"]) |
| f1.append(r["f1"]) |
| fpr.append(r["fpr"]) |
|
|
| if not names: |
| print(" SKIP fig 4 β no evaluation data found") |
| return |
|
|
| x = np.arange(len(names)) |
| width = 0.2 |
|
|
| fig, ax = plt.subplots(figsize=(13.0, 6.5)) |
| |
| ax.bar( |
| x - 1.5 * width, |
| precision, |
| width, |
| label="Precision", |
| color=TEAL, |
| alpha=0.85, |
| edgecolor="white", |
| zorder=3, |
| ) |
| ax.bar( |
| x - 0.5 * width, |
| recall, |
| width, |
| label="Recall", |
| color=MINT, |
| alpha=0.85, |
| edgecolor="white", |
| zorder=3, |
| ) |
| ax.bar( |
| x + 0.5 * width, |
| f1, |
| width, |
| label="F1-Score", |
| color=NAVY, |
| alpha=0.85, |
| edgecolor="white", |
| zorder=3, |
| ) |
| ax.bar( |
| x + 1.5 * width, |
| fpr, |
| width, |
| label="FPR", |
| color=CORAL, |
| alpha=0.85, |
| edgecolor="white", |
| zorder=3, |
| ) |
|
|
| ax.set_xticks(x) |
| ax.set_xticklabels(names, fontsize=16) |
| ax.set_ylabel("Score") |
| ax.set_ylim(0, 1.15) |
| ax.legend(loc="upper right", fontsize=15) |
|
|
| |
| if "CIC-IDS2018" in names: |
| cic_idx = names.index("CIC-IDS2018") |
| cic_prec = precision[cic_idx] |
| ax.annotate( |
| "* 453:1 imbalance", |
| xy=(cic_idx - 1.5 * width, cic_prec + 0.02), |
| xytext=(cic_idx - 0.5, 0.3), |
| fontsize=13, |
| color=CORAL, |
| arrowprops=dict(arrowstyle="->", color=CORAL, lw=1), |
| ) |
|
|
| plt.tight_layout() |
| out = FIG_DIR / f"04_cross_dataset_performance{_fig_suffix()}.png" |
| plt.savefig(out) |
| plt.close() |
| print(f" Saved: {out.name}") |
|
|
|
|
| |
| |
| |
| def fig_latency(): |
| keys = ["csic2010", "cicids2018", "unsw"] |
| dataset_labels = ["CSIC 2010", "CIC-IDS2018", "UNSW-NB15"] |
| model_keys = ["LSTM-Autoencoder", "Isolation Forest", "Random Forest"] |
| model_labels = ["LSTM-AE", "Isolation Forest", "Random Forest"] |
| colors = [TEAL, AMBER, CORAL] |
|
|
| latencies = {mk: [] for mk in model_keys} |
|
|
| for key in keys: |
| ev = load_eval(key) |
| for mk in model_keys: |
| val = ev.get(mk, {}).get("latency_ms", 0.0) |
| latencies[mk].append(val) |
|
|
| x = np.arange(len(dataset_labels)) |
| width = 0.25 |
|
|
| fig, ax = plt.subplots(figsize=(13.0, 6.5)) |
| |
| ymax = max(max(v) for v in latencies.values()) |
|
|
| for i, (mk, ml) in enumerate(zip(model_keys, model_labels)): |
| offset = (i - 1) * width |
| bars = ax.bar( |
| x + offset, |
| latencies[mk], |
| width, |
| label=ml, |
| color=colors[i], |
| alpha=0.85, |
| edgecolor="white", |
| zorder=3, |
| ) |
| for bar, val in zip(bars, latencies[mk]): |
| if val > 0: |
| ax.text( |
| bar.get_x() + bar.get_width() / 2, |
| bar.get_height() + ymax * 0.015, |
| f"{val:.3f}", |
| ha="center", |
| va="bottom", |
| fontsize=14, |
| ) |
|
|
| |
| |
| |
| lstm_v = latencies["LSTM-Autoencoder"] |
| if_v = latencies["Isolation Forest"] |
| rf_v = latencies["Random Forest"] |
| for xi in range(len(keys)): |
| if lstm_v[xi] > 0: |
| ax.annotate( |
| f"{if_v[xi] / lstm_v[xi]:.1f}\u00d7", |
| xy=(xi - width / 2, if_v[xi] + ymax * 0.10), |
| ha="center", |
| fontsize=15, |
| color=AMBER, |
| fontweight="bold", |
| ) |
| ax.annotate( |
| f"{rf_v[xi] / lstm_v[xi]:.1f}\u00d7", |
| xy=(xi + width / 2, rf_v[xi] + ymax * 0.10), |
| ha="center", |
| fontsize=15, |
| color=CORAL, |
| fontweight="bold", |
| ) |
|
|
| ax.set_ylim(0, ymax * 1.28) |
| ax.set_xticks(x) |
| ax.set_xticklabels(dataset_labels) |
| ax.set_ylabel("Latency (ms)") |
| |
| |
| |
| |
| |
| |
| |
| ax.legend( |
| fontsize=15, loc="lower center", bbox_to_anchor=(0.5, 1.01), |
| ncol=3, frameon=False, |
| ) |
| ax.text( |
| 0.02, |
| 0.62, |
| "\u00d7 annotations show speedup\nrelative to LSTM-AE", |
| transform=ax.transAxes, |
| fontsize=14, |
| color=GRAY, |
| va="top", |
| ) |
|
|
| plt.tight_layout() |
| out = FIG_DIR / f"05_latency_comparison{_fig_suffix()}.png" |
| plt.savefig(out) |
| plt.close() |
| print(f" Saved: {out.name}") |
|
|
|
|
| |
| |
| |
| def fig_active_learning(): |
| configs = [ |
| ("csic2010", "CSIC 2010", TEAL), |
| ("cicids2018", "CIC-IDS2018", AMBER), |
| ("unsw", "UNSW-NB15", PURPLE), |
| ] |
|
|
| fig, axes = _grid_2x2(n_used=3) |
| |
| for ax, (key, label, color) in zip(axes, configs): |
| history = load_al(key) |
| if not history: |
| ax.text( |
| 0.5, |
| 0.5, |
| "Data not found", |
| ha="center", |
| va="center", |
| transform=ax.transAxes, |
| ) |
| ax.set_title(label) |
| continue |
|
|
| iters = [h["iteration"] for h in history] |
| thresh = [h["threshold"] for h in history] |
| fpr_v = [h["fpr"] for h in history] |
| f1_v = [h["f1"] for h in history] |
|
|
| ax2 = ax.twinx() |
|
|
| (l1,) = ax.plot( |
| iters, |
| thresh, |
| color=color, |
| lw=2.5, |
| marker="o", |
| ms=6, |
| label="Threshold", |
| zorder=3, |
| ) |
| (l2,) = ax2.plot( |
| iters, |
| fpr_v, |
| color=CORAL, |
| lw=2, |
| marker="s", |
| ms=5, |
| linestyle="--", |
| label="FPR", |
| zorder=3, |
| ) |
| (l3,) = ax2.plot( |
| iters, |
| f1_v, |
| color=MINT, |
| lw=2, |
| marker="^", |
| ms=5, |
| linestyle=":", |
| label="F1", |
| zorder=3, |
| ) |
|
|
| ax.set_title(label) |
| ax.set_xlabel("AL Iteration") |
| ax.set_ylabel("Threshold", color=color) |
| ax2.set_ylabel("FPR / F1") |
| ax.tick_params(axis="y", labelcolor=color) |
| ax.legend( |
| [l1, l2, l3], ["Threshold", "FPR", "F1"], fontsize=14, loc="best" |
| ) |
|
|
| plt.tight_layout() |
| out = FIG_DIR / f"06_active_learning{_fig_suffix()}.png" |
| plt.savefig(out) |
| plt.close() |
| print(f" Saved: {out.name}") |
|
|
|
|
| |
| |
| |
| |
| def fig_error_separation(): |
| configs = [ |
| ("csic2010", "CSIC 2010", TEAL), |
| ("cicids2018", "CIC-IDS2018", AMBER), |
| ("unsw", "UNSW-NB15", PURPLE), |
| ] |
|
|
| fig, axes = _grid_2x2(n_used=3) |
| |
| rng = np.random.default_rng(42) |
|
|
| for ax, (key, name, color) in zip(axes, configs): |
| threshold = load_threshold(key) |
| thresh_data = load_threshold_full(key) |
| if threshold is None: |
| ax.set_title(name) |
| continue |
|
|
| real = load_real_errors(key) |
| if real is not None: |
| errors, labels = real |
| normal_e = errors[labels == 0] |
| attack_e = errors[labels == 1] |
| is_simulated = False |
| else: |
| mean_err = thresh_data.get("mean_error", threshold * 0.3) |
| std_err = min(thresh_data.get("std_error", threshold * 0.2), threshold * 1.5) |
|
|
| |
| normal_e = np.abs(rng.normal(mean_err * 0.4, std_err * 0.3, 2000)) |
| normal_e = np.clip(normal_e, 0, threshold * 0.9) |
|
|
| |
| attack_e = np.abs(rng.normal(threshold * 4, threshold * 2, 1000)) |
| attack_e = np.clip(attack_e, threshold * 0.3, threshold * 15) |
| is_simulated = True |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| eps = max(threshold * 1e-3, 1e-12) |
| normal_e = np.clip(normal_e, eps, None) |
| attack_e = np.clip(attack_e, eps, None) |
|
|
| bp = ax.boxplot( |
| [normal_e, attack_e], |
| tick_labels=["Normal", "Attack"], |
| patch_artist=True, |
| medianprops=dict(color=NAVY, lw=2.5), |
| whiskerprops=dict(lw=1.5), |
| capprops=dict(lw=1.5), |
| flierprops=dict(marker="o", markersize=2, alpha=0.3), |
| widths=0.5, |
| ) |
| bp["boxes"][0].set_facecolor(color) |
| bp["boxes"][0].set_alpha(0.6) |
| bp["boxes"][1].set_facecolor(CORAL) |
| bp["boxes"][1].set_alpha(0.6) |
|
|
| ax.axhline( |
| threshold, |
| color=NAVY, |
| lw=2, |
| linestyle="-.", |
| label=f"Ο = {threshold:.5f}", |
| zorder=4, |
| ) |
| ax.set_yscale("log") |
| ax.set_title(name) |
| ax.set_ylabel("Reconstruction Error (MSE, log scale)") |
| ax.legend(fontsize=14) |
|
|
| if is_simulated: |
| ax.text( |
| 0.98, 0.02, "SIMULATED\n(re-run evaluate.py to plot real errors)", |
| transform=ax.transAxes, ha="right", va="bottom", |
| fontsize=12.5, color=CORAL, fontweight="bold", |
| bbox=dict(boxstyle="round", facecolor="white", edgecolor=CORAL, alpha=0.85), |
| ) |
|
|
| plt.tight_layout() |
| out = FIG_DIR / f"07_error_separation{_fig_suffix()}.png" |
| plt.savefig(out) |
| plt.close() |
| print(f" Saved: {out.name}") |
|
|
|
|
| |
| |
| |
| |
| def fig_confusion_matrices(): |
| datasets = [ |
| ("csic2010", "CSIC 2010"), |
| ("cicids2018", "CIC-IDS2018"), |
| ("unsw", "UNSW-NB15"), |
| ] |
| model_order = ["LSTM-Autoencoder", "Isolation Forest", "Random Forest", "WAF Simulation"] |
| short_labels = { |
| "LSTM-Autoencoder": "LSTM-AE", |
| "Isolation Forest": "Isolation Forest", |
| "Random Forest": "Random Forest", |
| "WAF Simulation": "WAF Sim", |
| } |
|
|
| |
| |
| |
| any_saved = False |
| for key, label in datasets: |
| ev = load_eval(key) |
| if not ev: |
| print(f" SKIP fig 8 ({key}) β evaluation_{key} not found") |
| continue |
|
|
| fig, axes = _grid_2x2(figsize=(13.5, 12.0), n_used=4) |
|
|
| for ax, model in zip(axes, model_order): |
| r = ev.get(model) |
| if not r: |
| ax.text(0.5, 0.5, "no data", ha="center", va="center", transform=ax.transAxes) |
| ax.set_xticks([]); ax.set_yticks([]) |
| ax.set_title(short_labels[model], fontsize=17) |
| continue |
|
|
| any_saved = True |
| cm = np.array([[r["tn"], r["fp"]], [r["fn"], r["tp"]]]) |
| cm_norm = cm / cm.sum(axis=1, keepdims=True).clip(min=1) |
|
|
| ax.imshow(cm_norm, cmap="Blues", vmin=0, vmax=1, aspect="auto") |
| for i in range(2): |
| for j in range(2): |
| val = cm[i, j] |
| pct = cm_norm[i, j] * 100 |
| txt_color = "white" if cm_norm[i, j] > 0.5 else NAVY |
| ax.text( |
| j, i, f"{val:,}\n({pct:.1f}%)", |
| ha="center", va="center", fontsize=16, color=txt_color, |
| ) |
| ax.set_xticks([0, 1]); ax.set_xticklabels(["Normal", "Attack"], fontsize=15) |
| ax.set_yticks([0, 1]); ax.set_yticklabels(["Normal", "Attack"], fontsize=15) |
| ax.set_title(short_labels[model], fontsize=17) |
| ax.set_ylabel("True label", fontsize=15) |
| ax.set_xlabel("Predicted label", fontsize=15) |
|
|
| plt.tight_layout() |
| out = FIG_DIR / f"08_confusion_matrices_{key}{_fig_suffix()}.png" |
| plt.savefig(out) |
| plt.close() |
| print(f" Saved: {out.name}") |
|
|
| if not any_saved: |
| print(" SKIP fig 8 β no evaluation data found for any dataset") |
|
|
|
|
| |
| |
| |
| |
| def fig_accuracy_throughput(): |
| keys = ["csic2010", "cicids2018", "unsw"] |
| labels = ["CSIC 2010", "CIC-IDS2018", "UNSW-NB15"] |
| model_order = ["LSTM-Autoencoder", "Isolation Forest", "Random Forest", "WAF Simulation"] |
| short_labels = ["LSTM-AE", "Isolation\nForest", "Random\nForest", "WAF\nSim"] |
| colors = [TEAL, AMBER, CORAL, GRAY] |
|
|
| fig, axes = plt.subplots(1, 2, figsize=(18.2, 6.5)) |
| |
| |
| ax = axes[0] |
| x = np.arange(len(keys)) |
| width = 0.2 |
| any_acc = False |
| for i, model in enumerate(model_order): |
| vals = [] |
| for key in keys: |
| ev = load_eval(key) |
| vals.append(ev.get(model, {}).get("accuracy", np.nan)) |
| if not all(np.isnan(v) for v in vals): |
| any_acc = True |
| ax.bar(x + (i - 1.5) * width, vals, width, label=short_labels[i].replace("\n", " "), color=colors[i]) |
| ax.set_xticks(x); ax.set_xticklabels(labels) |
| ax.set_ylabel("Accuracy") |
| ax.set_ylim(0, 1.05) |
| ax.set_title("Accuracy by model and dataset") |
| ax.legend(fontsize=13) |
| if not any_acc: |
| ax.text(0.5, 0.5, "no data", ha="center", va="center", transform=ax.transAxes) |
|
|
| |
| ax = axes[1] |
| any_thr = False |
| for i, model in enumerate(model_order): |
| vals = [] |
| for key in keys: |
| ev = load_eval(key) |
| vals.append(ev.get(model, {}).get("throughput", np.nan)) |
| if not all(np.isnan(v) for v in vals): |
| any_thr = True |
| ax.bar(x + (i - 1.5) * width, vals, width, label=short_labels[i].replace("\n", " "), color=colors[i]) |
| ax.set_xticks(x); ax.set_xticklabels(labels) |
| ax.set_ylabel("Throughput (sessions/sec, log scale)") |
| ax.set_yscale("log") |
| ax.set_title("Inference throughput by model and dataset") |
| ax.legend(fontsize=13) |
| if not any_thr: |
| ax.text(0.5, 0.5, "no data", ha="center", va="center", transform=ax.transAxes) |
|
|
| if not (any_acc or any_thr): |
| print(" SKIP fig 9 β no evaluation data found") |
| plt.close() |
| return |
|
|
| plt.tight_layout() |
| out = FIG_DIR / f"09_accuracy_throughput{_fig_suffix()}.png" |
| plt.savefig(out) |
| plt.close() |
| print(f" Saved: {out.name}") |
|
|
|
|
| |
| |
| |
| |
| |
| def fig_active_learning_effort(): |
| configs = [ |
| ("csic2010", "CSIC 2010", TEAL), |
| ("cicids2018", "CIC-IDS2018", AMBER), |
| ("unsw", "UNSW-NB15", PURPLE), |
| ] |
|
|
| any_saved = False |
| for key, label, color in configs: |
| history = load_al(key) |
| if not history: |
| print(f" SKIP fig 10 ({key}) β active_learning_{key} not found") |
| continue |
|
|
| any_saved = True |
| fig, (ax_top, ax_bot) = plt.subplots(2, 1, figsize=(9.5, 12.5)) |
|
|
| iters = [h["iteration"] for h in history] |
| prec = [h["precision"] for h in history] |
| rec = [h["recall"] for h in history] |
| n_labeled = [h["n_labeled"] for h in history] |
| n_uncertain = [h.get("n_uncertain", 0) for h in history] |
|
|
| |
| ax_top.plot(iters, prec, "o-", color=color, lw=2, ms=6, label="Precision") |
| ax_top.plot(iters, rec, "s--", color=CORAL, lw=2, ms=5, label="Recall") |
| ax_top.set_title(label) |
| ax_top.set_xlabel("AL Iteration") |
| ax_top.set_ylabel("Score") |
| ax_top.set_ylim(0, 1.05) |
| ax_top.legend(fontsize=15) |
|
|
| |
| ax_bot.bar(iters, n_uncertain, alpha=0.35, color=GRAY, label="Uncertain pool size", zorder=2) |
| ax2 = ax_bot.twinx() |
| ax2.plot(iters, n_labeled, "^-", color=NAVY, lw=2, ms=6, label="Cumulative labeled", zorder=3) |
| ax_bot.set_xlabel("AL Iteration") |
| ax_bot.set_ylabel("Uncertain pool size", color=GRAY) |
| ax2.set_ylabel("Cumulative sessions labeled", color=NAVY) |
|
|
| lines1, labels1 = ax_bot.get_legend_handles_labels() |
| lines2, labels2 = ax2.get_legend_handles_labels() |
| ax_bot.legend(lines1 + lines2, labels1 + labels2, fontsize=13, loc="upper left") |
|
|
| plt.tight_layout() |
| out = FIG_DIR / f"10_active_learning_effort_{key}{_fig_suffix()}.png" |
| plt.savefig(out) |
| plt.close() |
| print(f" Saved: {out.name}") |
|
|
| if not any_saved: |
| print(" SKIP fig 10 β no active learning data found") |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| def fig_window_comparison(): |
| configs = [ |
| ("csic2010", "CSIC 2010", TEAL), |
| ("cicids2018", "CIC-IDS2018", AMBER), |
| ("unsw", "UNSW-NB15", PURPLE), |
| ] |
|
|
| fig, axes = _grid_2x2(n_used=3) |
| |
| any_data = False |
| for ax, (key, label, color) in zip(axes, configs): |
| windows = discover_windows(key) |
| if not windows: |
| ax.text(0.5, 0.5, "No trained windows found", ha="center", va="center", transform=ax.transAxes) |
| ax.set_title(label) |
| continue |
|
|
| f1s, recalls, fprs, threshes = [], [], [], [] |
| valid_windows = [] |
| for w in windows: |
| ev = load_eval_for(key, w) |
| lstm = ev.get("LSTM-Autoencoder") |
| if lstm is None: |
| continue |
| valid_windows.append(w) |
| f1s.append(lstm["f1"]) |
| recalls.append(lstm["recall"]) |
| fprs.append(lstm["fpr"]) |
|
|
| if not valid_windows: |
| ax.text( |
| 0.5, 0.5, "Trained but not\nevaluated yet", |
| ha="center", va="center", transform=ax.transAxes, |
| ) |
| ax.set_title(label) |
| continue |
|
|
| any_data = True |
| ax.plot(valid_windows, f1s, "o-", color=color, lw=2.5, ms=7, label="F1", zorder=3) |
| ax.plot(valid_windows, recalls, "^--", color=MINT, lw=2, ms=6, label="Recall", zorder=3) |
| ax.plot(valid_windows, fprs, "s:", color=CORAL, lw=2, ms=6, label="FPR", zorder=3) |
|
|
| ax.set_title(label) |
| ax.set_xlabel("Window size (sessions)") |
| ax.set_ylabel("Score") |
| ax.set_xticks(valid_windows) |
| ax.set_ylim(-0.02, 1.05) |
| ax.legend(fontsize=14) |
|
|
| if not any_data: |
| print(" SKIP fig 11 β no multi-window evaluation data found " |
| "(train + evaluate at more than one --window to populate this)") |
| plt.close() |
| return |
|
|
| plt.tight_layout() |
| out = FIG_DIR / "11_window_comparison.png" |
| plt.savefig(out) |
| plt.close() |
| print(f" Saved: {out.name}") |
|
|
|
|
| |
| |
| |
| |
| def fig_dataset_summary(): |
| panel_order = ["csic2010", "cicids2018_full", "cicids2018", "unsw"] |
| fig, axes = _grid_2x2(figsize=(15.0, 14.0), n_used=4) |
|
|
| def _autopct_fmt(pct): |
| |
| |
| |
| |
| |
| |
| if pct < 1 or pct > 99: |
| return f"{pct:.2f}%" |
| return f"{pct:.1f}%" |
|
|
| for ax, key in zip(axes, panel_order): |
| info = DATASET_COUNTS[key] |
| n = info["normal"] |
| a = info["attack"] |
| total = n + a |
| color = DATASET_COLORS[key] |
|
|
| _, _, autotexts = ax.pie( |
| [n / total * 100, a / total * 100], |
| explode=(0, 0.05), |
| labels=["Normal", "Attack"], |
| colors=[color, CORAL], |
| autopct=_autopct_fmt, |
| startangle=90, |
| pctdistance=0.75, |
| wedgeprops=dict(edgecolor="white", linewidth=2), |
| ) |
| for at in autotexts: |
| at.set_fontsize(15) |
| at.set_fontweight("bold") |
|
|
| ax.set_title(f"{info['label']}\n({total:,} total records)") |
| ax.text( |
| 0, |
| -1.35, |
| f"Normal: {n:,}\nAttack: {a:,}", |
| ha="center", |
| fontsize=14, |
| color="black", |
| ) |
|
|
| if key == "cicids2018": |
| |
| |
| |
| ax.text( |
| 0, |
| -1.68, |
| f"Used subset: 2 of 10 capture days\n" |
| f"({CICIDS2018_USED_ROWS:,} / " |
| f"{CICIDS2018_FULL_RELEASE_ROWS:,} rows, " |
| f"{CICIDS2018_USED_PCT:.1f}% of full release)", |
| ha="center", |
| fontsize=13, |
| color=CORAL, |
| fontweight="bold", |
| style="italic", |
| ) |
| elif key == "cicids2018_full": |
| ax.text( |
| 0, |
| -1.68, |
| "Includes DDoS/Bot/Infiltration days\n" |
| "outside this thesis's stated scope\n" |
| "(sequence-dependent API-logic attacks)", |
| ha="center", |
| fontsize=13, |
| color="black", |
| style="italic", |
| ) |
|
|
| plt.tight_layout() |
| plt.subplots_adjust(bottom=0.1, hspace=0.55) |
| out = FIG_DIR / f"12_dataset_summary{_fig_suffix()}.png" |
| plt.savefig(out) |
| plt.close() |
| print(f" Saved: {out.name}") |
|
|
|
|
| |
| |
| |
| def _parse_args(): |
| import argparse |
|
|
| parser = argparse.ArgumentParser( |
| description="Generate thesis figures from saved pipeline results." |
| ) |
| parser.add_argument( |
| "--window", |
| type=int, |
| default=None, |
| help="Window size to use for ALL three datasets (e.g. 5, 8, 10). " |
| "Overrides WINDOW_BY_DATASET so you can re-run this script once " |
| "per window size you've trained/evaluated.", |
| ) |
| parser.add_argument( |
| "--window-csic2010", |
| type=int, |
| default=None, |
| help="Override window size for csic2010 only (takes precedence over --window).", |
| ) |
| parser.add_argument( |
| "--window-cicids2018", |
| type=int, |
| default=None, |
| help="Override window size for cicids2018 only (takes precedence over --window).", |
| ) |
| parser.add_argument( |
| "--window-unsw", |
| type=int, |
| default=None, |
| help="Override window size for unsw only (takes precedence over --window).", |
| ) |
| return parser.parse_args() |
|
|
|
|
| if __name__ == "__main__": |
| args = _parse_args() |
|
|
| |
| |
| if args.window is not None: |
| for key in WINDOW_BY_DATASET: |
| WINDOW_BY_DATASET[key] = args.window |
| if args.window_csic2010 is not None: |
| WINDOW_BY_DATASET["csic2010"] = args.window_csic2010 |
| if args.window_cicids2018 is not None: |
| WINDOW_BY_DATASET["cicids2018"] = args.window_cicids2018 |
| if args.window_unsw is not None: |
| WINDOW_BY_DATASET["unsw"] = args.window_unsw |
|
|
| print(f"Output directory: {FIG_DIR}\n") |
| print("Window sizes in use:", WINDOW_BY_DATASET, "\n") |
|
|
| print("ββ Data loaded from disk βββββββββββββββββββββββββββββββββββββββββ") |
| for key in ["csic2010", "cicids2018", "unsw"]: |
| label = DATASET_COUNTS[key]["label"].replace("\n", " ") |
| run_id = _run_id(key) |
| h = load_history(key) |
| t = load_threshold_full(key) |
| ev = load_eval(key) |
| al = load_al(key) |
|
|
| epochs = len(h.get("epochs", [])) |
| threshold = t.get("threshold", "N/A") |
| lstm = ev.get("LSTM-Autoencoder", {}) |
| f1 = lstm.get("f1", "N/A") |
| recall = lstm.get("recall", "N/A") |
| latency = lstm.get("latency_ms", "N/A") |
| al_iters = len(al) |
|
|
| thresh_str = f"{threshold:.6f}" if isinstance(threshold, (int, float)) else str(threshold) |
| print( |
| f" {label:<14} ({run_id}) " |
| f"epochs={epochs} " |
| f"Ο={thresh_str} " |
| f"F1={f1} " |
| f"Rec={recall} " |
| f"Lat={latency}ms " |
| f"AL_iters={al_iters}" |
| ) |
| print() |
|
|
| print("ββ Generating figures ββββββββββββββββββββββββββββββββββββββββββββ") |
| fig_learning_curves() |
| fig_error_distributions() |
| fig_model_comparison() |
| fig_cross_dataset() |
| fig_latency() |
| fig_active_learning() |
| fig_error_separation() |
| fig_confusion_matrices() |
| fig_accuracy_throughput() |
| fig_active_learning_effort() |
| fig_window_comparison() |
| fig_dataset_summary() |
|
|
| print(f"\nDone β up to 12 figures saved to {FIG_DIR}/ (some may be skipped if data is missing)") |
|
|