File size: 12,127 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
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
"""
train_balanced.py
==================
Trains the two balanced-training ablation variants on top of the
splits produced by balance_data.py:

  --mode balanced_recon
      Same unsupervised reconstruction-error training as train.py's
      normal-only model -- MSE loss, no labels used -- but fed the
      balanced normal+attack training set instead of normal-only.
      Tests whether reconstruction error still separates the classes
      when the model is trained to reconstruct BOTH well.

  --mode balanced_supervised
      Labels are used directly. The encoder's bottleneck vector feeds
      a small classifier head trained with binary cross-entropy,
      combined with the reconstruction loss (weighted). This is
      structurally closest to the Elsayed et al. (LSTM-AE + OC-SVM
      on the latent representation) precedent already cited in
      Literature.tex, except the classifier head is trained jointly
      rather than as a separate downstream SVM step.

Both variants share the same encoder/decoder architecture, hidden
size, window size, and seed as the canonical normal-only model in
train.py -- only the training data and (for balanced_supervised) the
loss function differ. Run balance_data.py first to generate the
required *_balanced_{run_id}.npy arrays.

Usage
-----
python balance_data.py --dataset csic2010 --window 5
python train_balanced.py --dataset csic2010 --mode balanced_recon
python train_balanced.py --dataset csic2010 --mode balanced_supervised

Author : K.A.D.S.D. Kandanaarachchi (2020/ICT/19)
Project: Detecting Anomalous REST API Traffic -- IT4216 (balanced-
         training ablation)
"""

import argparse
import json
import logging
import random
from pathlib import Path

import numpy as np
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset

from model import (
    LSTMAutoencoder,
    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 set_seed(seed=42):
    random.seed(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)
    torch.backends.cudnn.deterministic = True


class LSTMAutoencoderWithHead(nn.Module):
    """
    Wraps the existing LSTMAutoencoder (encoder+decoder unchanged) and
    adds a small linear classifier head on top of the encoder's
    bottleneck vector, for the balanced_supervised variant.

    Kept as a separate wrapper class -- rather than modifying
    model.py's LSTMAutoencoder directly -- so the canonical
    normal-only model and all existing scripts (evaluate.py,
    active_learning.py, tui.py) are completely unaffected by this
    ablation study.
    """

    def __init__(self, base_model: LSTMAutoencoder, hidden_size: int = 64):
        super().__init__()
        self.base = base_model
        self.classifier = nn.Linear(hidden_size, 1)

    def forward(self, x):
        if self.base.input_mode == "embedding":
            x_emb = self.base.embedding(x)
        else:
            x_emb = x
        context = self.base.encoder(x_emb)          # bottleneck vector
        reconstruction = self.base.decoder(context)
        logit = self.classifier(context).squeeze(-1)
        return reconstruction, logit, x_emb

    def reconstruction_error(self, x):
        with torch.no_grad():
            recon, _, x_emb = self.forward(x)
            error = ((recon - x_emb) ** 2).mean(dim=(1, 2))
        return error

    def predict_proba(self, x):
        with torch.no_grad():
            _, logit, _ = self.forward(x)
            return torch.sigmoid(logit)


def _to_tensor(X: np.ndarray, dataset: str) -> torch.Tensor:
    if dataset == "csic2010":
        return torch.tensor(X, dtype=torch.long)
    X = np.nan_to_num(X, nan=0.0, posinf=0.0, neginf=0.0)
    return torch.tensor(X, dtype=torch.float32)


def train_balanced_recon(
    model, X_train, dataset, run_id, epochs, batch_size, lr, device
):
    """
    Reconstruction-only training on the balanced set -- identical
    training loop to train.py's train(), just fed balanced data
    instead of normal-only data. No labels used.
    """
    model = model.to(device)
    optimizer = torch.optim.Adam(model.parameters(), lr=lr)
    criterion = nn.MSELoss()

    split = int(len(X_train) * 0.9)
    X_tr, X_val = X_train[:split], X_train[split:]
    tr_tensor, val_tensor = _to_tensor(X_tr, dataset), _to_tensor(X_val, dataset)

    tr_loader = DataLoader(TensorDataset(tr_tensor), batch_size=batch_size, shuffle=True)
    val_loader = DataLoader(TensorDataset(val_tensor), batch_size=batch_size)

    train_losses, val_losses = [], []
    best_val = float("inf")

    for epoch in range(1, epochs + 1):
        model.train()
        epoch_loss = 0.0
        for (batch,) in tr_loader:
            batch = batch.to(device)
            optimizer.zero_grad()
            recon = model(batch)
            target = model.embedding(batch).detach() if dataset == "csic2010" else batch
            loss = criterion(recon, target)
            loss.backward()
            nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
            optimizer.step()
            epoch_loss += loss.item() * len(batch)
        epoch_loss /= len(X_tr)

        model.eval()
        val_loss = 0.0
        with torch.no_grad():
            for (batch,) in val_loader:
                batch = batch.to(device)
                recon = model(batch)
                target = model.embedding(batch).detach() if dataset == "csic2010" else batch
                val_loss += criterion(recon, target).item() * len(batch)
        val_loss /= len(X_val)

        train_losses.append(epoch_loss)
        val_losses.append(val_loss)
        log.info("Epoch %02d/%02d  train=%.6f  val=%.6f", epoch, epochs, epoch_loss, val_loss)

        if val_loss < best_val - 1e-6:
            best_val = val_loss
            torch.save(model.state_dict(), f"models/best_{run_id}_balanced_recon.pt")

    return train_losses, val_losses


def train_balanced_supervised(
    model, X_train, y_train, dataset, run_id, epochs, batch_size, lr, device,
    recon_weight: float = 0.5,
):
    """
    Joint reconstruction + classification training.

    Loss = recon_weight * MSE(reconstruction, input)
         + (1 - recon_weight) * BCE(classifier_logit, label)

    recon_weight=0.5 is a starting point, not a tuned value -- treat
    it as a hyperparameter to sweep if time allows. At recon_weight=0
    this degenerates to a pure sequence classifier (no autoencoding
    objective at all); at 1.0 it's identical to balanced_recon and the
    classifier head is trained but never influences the shared
    encoder weights via backprop on the classification loss.
    """
    model = model.to(device)
    optimizer = torch.optim.Adam(model.parameters(), lr=lr)
    mse = nn.MSELoss()
    bce = nn.BCEWithLogitsLoss()

    split = int(len(X_train) * 0.9)
    X_tr, X_val = X_train[:split], X_train[split:]
    y_tr, y_val = y_train[:split], y_train[split:]

    tr_tensor, val_tensor = _to_tensor(X_tr, dataset), _to_tensor(X_val, dataset)
    y_tr_t = torch.tensor(y_tr, dtype=torch.float32)
    y_val_t = torch.tensor(y_val, dtype=torch.float32)

    tr_loader = DataLoader(TensorDataset(tr_tensor, y_tr_t), batch_size=batch_size, shuffle=True)
    val_loader = DataLoader(TensorDataset(val_tensor, y_val_t), batch_size=batch_size)

    train_losses, val_losses = [], []
    best_val = float("inf")

    for epoch in range(1, epochs + 1):
        model.train()
        epoch_loss = 0.0
        for batch, labels in tr_loader:
            batch, labels = batch.to(device), labels.to(device)
            optimizer.zero_grad()
            recon, logit, x_emb = model(batch)
            loss = recon_weight * mse(recon, x_emb) + (1 - recon_weight) * bce(logit, labels)
            loss.backward()
            nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
            optimizer.step()
            epoch_loss += loss.item() * len(batch)
        epoch_loss /= len(X_tr)

        model.eval()
        val_loss = 0.0
        correct = 0
        with torch.no_grad():
            for batch, labels in val_loader:
                batch, labels = batch.to(device), labels.to(device)
                recon, logit, x_emb = model(batch)
                loss = recon_weight * mse(recon, x_emb) + (1 - recon_weight) * bce(logit, labels)
                val_loss += loss.item() * len(batch)
                pred = (torch.sigmoid(logit) > 0.5).float()
                correct += (pred == labels).sum().item()
        val_loss /= len(X_val)
        val_acc = correct / len(X_val)

        train_losses.append(epoch_loss)
        val_losses.append(val_loss)
        log.info(
            "Epoch %02d/%02d  train=%.6f  val=%.6f  val_acc=%.4f",
            epoch, epochs, epoch_loss, val_loss, val_acc,
        )

        if val_loss < best_val - 1e-6:
            best_val = val_loss
            torch.save(model.state_dict(), f"models/best_{run_id}_balanced_supervised.pt")

    return train_losses, val_losses


def main():
    set_seed(42)
    parser = argparse.ArgumentParser()
    parser.add_argument("--dataset", required=True, choices=["csic2010", "cicids2018", "unsw"])
    parser.add_argument("--mode", required=True, choices=["balanced_recon", "balanced_supervised"])
    parser.add_argument("--window", type=int, default=5)
    parser.add_argument("--epochs", type=int, default=30)
    parser.add_argument("--batch_size", type=int, default=256)
    parser.add_argument("--lr", type=float, default=1e-3)
    parser.add_argument("--recon_weight", type=float, default=0.5)
    args = parser.parse_args()

    device = "cuda" if torch.cuda.is_available() else "cpu"
    data_dir = Path("data/processed")
    model_dir = Path("models")
    model_dir.mkdir(exist_ok=True)
    run_id = f"{args.dataset}_w{args.window}"

    X_train = np.load(data_dir / f"X_train_balanced_{run_id}.npy")
    y_train = np.load(data_dir / f"y_train_balanced_{run_id}.npy")
    log.info(
        "Loaded balanced training set: %s  (normal=%d attack=%d)",
        X_train.shape, int((y_train == 0).sum()), int((y_train == 1).sum()),
    )

    if args.dataset == "csic2010":
        vocab_data = json.load(open(data_dir / f"vocab_{run_id}.json"))
        vocab = vocab_data.get("vocab", vocab_data)
        base_model = build_model_csic2010(vocab_size=len(vocab), seq_len=args.window)
    elif args.dataset == "cicids2018":
        base_model = build_model_cicids2018(n_features=X_train.shape[2], seq_len=args.window)
    else:
        base_model = build_model_unsw(n_features=X_train.shape[2], seq_len=args.window)

    if args.mode == "balanced_recon":
        train_losses, val_losses = train_balanced_recon(
            base_model, X_train, args.dataset, run_id,
            args.epochs, args.batch_size, args.lr, device,
        )
        suffix = "balanced_recon"
    else:
        model = LSTMAutoencoderWithHead(base_model, hidden_size=64)
        train_losses, val_losses = train_balanced_supervised(
            model, X_train, y_train, args.dataset, run_id,
            args.epochs, args.batch_size, args.lr, device,
            recon_weight=args.recon_weight,
        )
        suffix = "balanced_supervised"

    history = {
        "dataset": args.dataset,
        "window": args.window,
        "mode": args.mode,
        "epochs": list(range(1, len(train_losses) + 1)),
        "train_loss": train_losses,
        "val_loss": val_losses,
    }
    history_path = model_dir / f"history_{run_id}_{suffix}.json"
    with open(history_path, "w") as f:
        json.dump(history, f, indent=2)
    log.info("History saved -> %s", history_path)
    log.info("Best model saved -> models/best_%s_%s.pt", run_id, suffix)
    log.info(
        "NEXT: evaluate against data/processed/X_test_balanced_%s.npy "
        "(NOT the canonical X_test_%s.npy -- different, smaller test "
        "population; see balance_data.py's printed summary).",
        run_id, run_id,
    )


if __name__ == "__main__":
    main()