File size: 10,339 Bytes
1058c94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
"""
balance_data.py
================
Builds a SEPARATE balanced train/test split for the balanced-training
ablation study, without touching the existing normal-only arrays used
by Chapter 4's canonical results.

Why this exists
----------------
preprocessing.py currently puts 100% of attack sessions into the test
set and 0% into train (train = normal-only, by design, for the
unsupervised model). To train a model on a roughly-balanced mix of
normal + attack sessions, some attack sessions must move into a
training split -- which necessarily means this ablation study uses a
DIFFERENT, smaller test set than Table tab:lstm_results. That's fine,
but it must be reported as such, not silently compared 1:1 against
the canonical normal-only numbers.

What this script does
----------------------
1. Loads the existing X_test_{dataset}_w{window}.npy / y_test_*.npy
   (the only place attack sessions currently exist on disk).
2. Splits the ATTACK sessions 80/20 into attack_train / attack_test,
   mirroring how normal traffic is already split in preprocessing.py.
3. Builds a balanced training set: attack_train sessions + an equal
   (or ratio-controlled) number of normal sessions, drawn from the
   existing normal-only X_train (for csic2010/cicids2018/unsw this is
   the *_train_{run_id}.npy already on disk).
4. Builds a new, smaller balanced test set: attack_test + a matching
   slice of held-out normal sessions (drawn from whatever normal
   sessions were NOT used in the balanced training set, so nothing
   leaks between train/test).
5. Saves everything under a "_balanced" tag so the original arrays
   are never modified:
       X_train_balanced_{run_id}.npy
       y_train_balanced_{run_id}.npy   (0=normal, 1=attack -- needed
                                         for the supervised variant;
                                         harmless/unused for the
                                         reconstruction-only variant)
       X_test_balanced_{run_id}.npy
       y_test_balanced_{run_id}.npy

Usage
-----
python balance_data.py --dataset csic2010 --window 5
python balance_data.py --dataset cicids2018 --window 5 --normal-ratio 1.0
python balance_data.py --dataset unsw --window 5

--normal-ratio controls the normal:attack ratio in the TRAINING set
(1.0 = strictly balanced 1:1). CIC-IDS2018 only has 924 attack test
sessions to draw from -- see the printed summary for exactly how many
sessions end up in each split before you commit to a training run.

Author : K.A.D.S.D. Kandanaarachchi (2020/ICT/19)
Project: Detecting Anomalous REST API Traffic -- IT4216 (balanced-
         training ablation, added after supervisor/co-supervisor
         discussion on normal-only training methodology)
"""

import argparse
import logging
from pathlib import Path

import numpy as np

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s  %(levelname)s  %(message)s",
    datefmt="%H:%M:%S",
)
log = logging.getLogger(__name__)

SEED = 42


def build_balanced_split(
    dataset: str,
    window: int,
    data_dir: Path,
    attack_train_ratio: float = 0.8,
    normal_ratio: float = 1.0,
) -> dict:
    """
    Parameters
    ----------
    attack_train_ratio : fraction of attack sessions moved into the
        balanced training set (rest held out as balanced test attacks).
        0.8 mirrors the normal-traffic train/test split already used
        elsewhere in this pipeline.
    normal_ratio : ratio of normal:attack sessions in the TRAINING set.
        1.0 = perfectly balanced (equal counts). Set >1.0 if you decide
        with your co-supervisor that strict 1:1 throws away too much
        signal for a given dataset (e.g. CIC-IDS2018).

    Returns a dict summary (also printed) so you can sanity-check
    counts before committing to a full training run.
    """
    rng = np.random.default_rng(SEED)
    run_id = f"{dataset}_w{window}"

    X_test_all = np.load(data_dir / f"X_test_{run_id}.npy")
    y_test_all = np.load(data_dir / f"y_test_{run_id}.npy")
    X_train_normal_pool = np.load(data_dir / f"X_train_{run_id}.npy")

    attack_idx = np.where(y_test_all == 1)[0]
    normal_test_idx = np.where(y_test_all == 0)[0]

    n_attack_total = len(attack_idx)
    log.info("%s: %d attack sessions available in existing test set", dataset, n_attack_total)

    # Shuffle attacks, split 80/20 train/test (mirrors normal split elsewhere)
    shuffled_attack_idx = rng.permutation(attack_idx)
    n_attack_train = int(round(n_attack_total * attack_train_ratio))
    attack_train_idx = shuffled_attack_idx[:n_attack_train]
    attack_test_idx = shuffled_attack_idx[n_attack_train:]

    X_attack_train = X_test_all[attack_train_idx]
    X_attack_test = X_test_all[attack_test_idx]

    # How many normal sessions needed for the training set, given normal_ratio
    n_normal_train_needed = int(round(len(attack_train_idx) * normal_ratio))
    if n_normal_train_needed > len(X_train_normal_pool):
        log.warning(
            "  Requested %d normal training sessions but only %d available "
            "in the normal-only pool -- using all of them (ratio will be "
            "lower than requested).",
            n_normal_train_needed, len(X_train_normal_pool),
        )
        n_normal_train_needed = len(X_train_normal_pool)

    normal_train_sel = rng.choice(
        len(X_train_normal_pool), n_normal_train_needed, replace=False
    )
    X_normal_train = X_train_normal_pool[normal_train_sel]

    # Remaining normal sessions (not used in training) are available for
    # the balanced test set, so nothing leaks between splits.
    remaining_normal_mask = np.ones(len(X_train_normal_pool), dtype=bool)
    remaining_normal_mask[normal_train_sel] = False
    remaining_normal_pool = X_train_normal_pool[remaining_normal_mask]

    # Also fold in the ORIGINAL held-out normal test sessions -- these
    # were never used for training under any variant, so they're safe
    # to reuse here too.
    normal_test_pool = np.concatenate(
        [remaining_normal_pool, X_test_all[normal_test_idx]], axis=0
    )

    n_normal_test_needed = min(len(X_attack_test) * 4, len(normal_test_pool))
    # ^ keep test set realistically imbalanced-ish (4:1) rather than
    # forcing 1:1 at test time too -- balancing is a TRAINING decision;
    # evaluation should still reflect a plausible operating distribution.
    # Adjust this multiplier with your co-supervisor if you want the
    # balanced-test evaluation itself to also be strictly 1:1.
    normal_test_sel = rng.choice(
        len(normal_test_pool), n_normal_test_needed, replace=False
    )
    X_normal_test = normal_test_pool[normal_test_sel]

    # Assemble final arrays
    X_train_balanced = np.concatenate([X_normal_train, X_attack_train], axis=0)
    y_train_balanced = np.concatenate(
        [np.zeros(len(X_normal_train), dtype=np.int32),
         np.ones(len(X_attack_train), dtype=np.int32)]
    )
    X_test_balanced = np.concatenate([X_normal_test, X_attack_test], axis=0)
    y_test_balanced = np.concatenate(
        [np.zeros(len(X_normal_test), dtype=np.int32),
         np.ones(len(X_attack_test), dtype=np.int32)]
    )

    # Shuffle train (test order doesn't matter for evaluation)
    perm = rng.permutation(len(X_train_balanced))
    X_train_balanced = X_train_balanced[perm]
    y_train_balanced = y_train_balanced[perm]

    # Save under a distinct tag -- originals are never touched
    np.save(data_dir / f"X_train_balanced_{run_id}.npy", X_train_balanced)
    np.save(data_dir / f"y_train_balanced_{run_id}.npy", y_train_balanced)
    np.save(data_dir / f"X_test_balanced_{run_id}.npy", X_test_balanced)
    np.save(data_dir / f"y_test_balanced_{run_id}.npy", y_test_balanced)

    summary = {
        "dataset": dataset,
        "window": window,
        "train_normal": len(X_normal_train),
        "train_attack": len(X_attack_train),
        "train_ratio_normal_to_attack": round(len(X_normal_train) / max(len(X_attack_train), 1), 3),
        "test_normal": len(X_normal_test),
        "test_attack": len(X_attack_test),
    }
    log.info("=" * 55)
    log.info("BALANCED SPLIT SUMMARY -- %s", dataset.upper())
    log.info("=" * 55)
    for k, v in summary.items():
        log.info("  %-30s %s", k, v)
    log.info(
        "  NOTE: this test set (%d normal / %d attack) is DIFFERENT from "
        "the canonical Chapter 4 test set (%d normal / %d attack). "
        "Report balanced-training results against THIS test set only -- "
        "do not compare its metrics directly to Table tab:lstm_results "
        "without re-stating that the test populations differ.",
        summary["test_normal"], summary["test_attack"],
        len(normal_test_idx), n_attack_total,
    )

    if dataset == "cicids2018":
        log.info(
            "  SCOPE NOTE: attack sessions here are drawn only from the "
            "928 web/brute-force attack flows in the two capture days "
            "(02-22/23-2018) this study uses -- NOT the full CIC-IDS2018 "
            "dataset, which also includes DDoS, Botnet, Infiltration, and "
            "other volumetric/network-layer attack days outside this "
            "study's stated scope of sequence-dependent API-logic attacks "
            "(Section 1.5). This is why balancing this dataset requires "
            "aggressive normal undersampling (or SMOTE) rather than more "
            "attack volume -- pulling in the other capture days would "
            "ease balancing but would silently broaden the thesis's scope."
        )

    return summary


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--dataset", required=True, choices=["csic2010", "cicids2018", "unsw"])
    parser.add_argument("--window", type=int, default=5)
    parser.add_argument("--attack-train-ratio", type=float, default=0.8)
    parser.add_argument(
        "--normal-ratio", type=float, default=1.0,
        help="normal:attack ratio in the TRAINING set (1.0 = balanced)",
    )
    parser.add_argument("--data-dir", default="data/processed")
    args = parser.parse_args()

    build_balanced_split(
        dataset=args.dataset,
        window=args.window,
        data_dir=Path(args.data_dir),
        attack_train_ratio=args.attack_train_ratio,
        normal_ratio=args.normal_ratio,
    )


if __name__ == "__main__":
    main()