File size: 18,622 Bytes
908fd1a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
"""Datasets y loaders del motor VX-One (spec F2 Β§0.1, Β§6.2, D12).

- UNSW / CIC: tensores completos en RAM (UNSW 149KΓ—40Γ—4B β‰ˆ 24 MB;
  CIC train 1.98MΓ—78Γ—4B β‰ˆ 620 MB).
- EMBER: streaming por shards (46 train parquets de 60K filas; ~616 MB
  decodificados c/u): por epoch se permutan los shards (seed 42+epoch),
  shuffle intra-shard, batches con drop_last; prefetch de 1 shard en thread.
  Shuffle de dos niveles (shard + intra) β€” aproximaciΓ³n documentada (Β§6.2).

Candidate set por corrida (D12): familias nativas con β‰₯1 fila en TRAIN bajo
el esquema de la corrida, en el ORDEN CONGELADO del registro. Pesos Ο‰
(Β§3.5) derivados de los conteos de train DE LA CORRIDA (LOAO los recalcula).

Ο† (feature stage) se ajusta SOLO con train:
- UNSW/CIC: train completo en RAM (float64).
- EMBER: submuestra uniforme de 60,000 filas de train (1,304 por shard,
  rng seed 42, una pasada por los 46 shards) β€” la SE de la mediana con
  n=60K es despreciable y evita 28 GB en RAM; decisiΓ³n documentada.
"""

from __future__ import annotations

import threading
from dataclasses import dataclass, field
from pathlib import Path

import numpy as np
import pyarrow.parquet as pq
import torch

from src.state.state import SOURCE_REGISTRY

from .feature_stage import FeatureStage
from .losses import class_weights
from .network import HeadSpec
from .vocab import TextVocab

ROOT = Path(__file__).resolve().parents[2]
DATA = ROOT / "data" / "processed"

REGISTRY = SOURCE_REGISTRY
ALIAS = {"unsw": "unsw_nb15_v1", "cic": "cicids2017_v1", "ember": "ember2024_v1"}


def _registry(source: str) -> dict:
    """Alias corto (unsw/cic/ember) o clave de registro β†’ entrada del registro."""
    return REGISTRY[ALIAS.get(source, source)]

DDOS_DAYS = {
    "Wednesday-workingHours.pcap_ISCX",
    "Friday-WorkingHours-Afternoon-DDos.pcap_ISCX",
}
DDOS_FULL = {"BENIGN", "DDoS", "DoS Hulk", "DoS GoldenEye", "DoS slowloris", "DoS Slowhttptest"}
DDOS_CANDIDATES = ("normal", "tcp_flood", "http_dos", "slowloris")  # orden congelado F2 D8
_DDOS_MAP = {"BENIGN": "normal", "DDoS": "tcp_flood", "DoS Hulk": "http_dos",
             "DoS GoldenEye": "http_dos", "DoS slowloris": "slowloris",
             "DoS Slowhttptest": "slowloris"}

EMBER_STATS_ROWS_PER_SHARD = 1_304  # Γ— 46 shards = 60,024 filas de stats


@dataclass
class Labels:
    y_bin: torch.Tensor        # int64 [N] 1[family != normal] (o label_binary en EMBER)
    y_fam: torch.Tensor        # int64 [N] Γ­ndice en candidates
    y_sev: torch.Tensor | None # int64 [N] 0..3 (None en EMBER)
    ddos_mask: torch.Tensor | None  # bool [N] filas del subconjunto (solo CIC strat)
    y_ddos: torch.Tensor | None     # int64 [N] Γ­ndice en DDOS_CANDIDATES


@dataclass
class RunData:
    """Todo lo que una corrida necesita, materializado y verificado."""
    source: str
    scale: str
    split_scheme: str          # "oficial" (UNSW) | "strat" | "time" (CIC) | "temporal" (EMBER)
    candidates: list[str]
    ddos_candidates: list[str] | None
    omega: torch.Tensor        # float32 [K]
    omega_ddos: torch.Tensor | None
    vocab: dict[str, TextVocab] | None
    feature_stage: FeatureStage
    heads: HeadSpec
    train: torch.Tensor        # u_r float32 [Ntr, d+1]
    train_txt: dict[str, torch.Tensor] | None
    train_labels: Labels
    val: torch.Tensor
    val_txt: dict[str, torch.Tensor] | None
    val_labels: Labels
    test: torch.Tensor | None  # None en EMBER (streaming, no se materializa)
    test_txt: dict[str, torch.Tensor] | None
    test_labels: Labels | None
    val_fam_names: np.ndarray | None = None   # familia nativa por fila (E2; -1 = fuera de candidatos)
    test_fam_names: np.ndarray | None = None
    ember_train_shards: list[Path] | None = None
    ember_val_shards: list[Path] | None = None
    ember_test_shards: list[Path] | None = None
    meta: dict = field(default_factory=dict)


# ── utilidades de lectura ──────────────────────────────────────────────
def _read_x_num(path: Path) -> np.ndarray:
    tbl = pq.read_table(path, columns=["x_num"])
    arr = tbl.column("x_num").combine_chunks()
    flat = arr.flatten().to_numpy(zero_copy_only=False)
    d = arr.type.list_size
    return np.asarray(flat, dtype=np.float64).reshape(-1, d)


def _frozen_candidates(source: str, fam_counts: dict[str, int]) -> list[str]:
    """D12: familias nativas con β‰₯1 fila en train, en orden congelado."""
    native = _registry(source)["native_families"]
    cands = [f for f in native if fam_counts.get(f, 0) > 0]
    faltan = sorted(set(native) - set(cands))
    if faltan:
        print(f"[d12] {source}: sin filas en train β†’ fuera del candidate set: {faltan}")
    return cands


def _idx_map(candidates: list[str]) -> dict[str, int]:
    return {c: i for i, c in enumerate(candidates)}


# ── UNSW / CIC: datasets en RAM ────────────────────────────────────────
def _load_network_split(path: Path, source: str, split_col: str, split: str,
                        candidates: list[str], need_ddos: bool,
                        exclude_family: str | None = None
                        ) -> tuple[np.ndarray, dict[str, list[str | None]], Labels]:
    txt_keys = ("proto", "service", "state") if source == "unsw" else ("protocol",)
    cols = ["x_num", split_col, "label_family", "label_severity"]
    cols += [f"x_txt_{k}" for k in txt_keys]
    if need_ddos:
        cols += ["day", "label_full"]  # el subset usa etiquetas crudas (label_full)
    tbl = pq.read_table(path, columns=cols)
    mask = np.asarray(tbl.column(split_col).to_pylist()) == split
    x = _read_x_num(path)[mask]
    fam = np.asarray(tbl.column("label_family").to_pylist())[mask]
    sev = np.asarray(tbl.column("label_severity").to_pylist())[mask]
    txt = {k: list(np.asarray(tbl.column(f"x_txt_{k}").to_pylist())[mask])
           for k in txt_keys}

    # LOAO: la familia excluida sale de train Y val (Β§6.7); test se evalΓΊa completo
    if exclude_family is not None and split != "test":
        keep = fam != exclude_family
        x, fam, sev = x[keep], fam[keep], sev[keep]
        txt = {k: [v for v, kp in zip(vals, keep) if kp] for k, vals in txt.items()}

    imap = _idx_map(candidates)
    # filas con familia fuera del candidate set (D12: excluida LOAO en test,
    # o recon/botnet en val/test de split_time) β†’ sentinela -1; los nombres
    # se conservan para E2 (evaluaciΓ³n de la familia excluida, Β§6.7)
    y_fam = torch.tensor([imap.get(f, -1) for f in fam], dtype=torch.int64)
    y_bin = torch.tensor([f != "normal" for f in fam], dtype=torch.int64)
    y_sev = torch.tensor(sev, dtype=torch.int64)

    if need_ddos:
        # subset congelado (SCHEMA Β§3.3): dΓ­as Wednesday+Friday-DDoS; solo
        # mapean a las 4 clases las filas con etiqueta nativa en DDOS_FULL
        # (642,862 de las 918,437; el resto del dΓ­a no entrena este head)
        day = np.asarray(tbl.column("day").to_pylist())[mask]
        full = np.asarray(tbl.column("label_full").to_pylist())[mask]
        if exclude_family is not None and split != "test":
            day, full = day[keep], full[keep]  # misma poda LOAO que x/fam
        in_subset = np.asarray([d in DDOS_DAYS and f in DDOS_FULL
                                for d, f in zip(day, full)])
        ddos_mask = torch.tensor(in_subset)
        y_ddos = torch.zeros(len(fam), dtype=torch.int64)  # alineado a la fila
        y_ddos[ddos_mask] = torch.tensor(
            [_idx_map(DDOS_CANDIDATES)[_DDOS_MAP[f]] for f in np.asarray(full)[in_subset]],
            dtype=torch.int64)
    else:
        ddos_mask = y_ddos = None
    return x, txt, Labels(y_bin, y_fam, y_sev, ddos_mask, y_ddos), fam


def build_network_run(source: str, scale: str, split_scheme: str,
                      exclude_family: str | None = None) -> RunData:
    path = DATA / ("unsw_nb15/unsw_nb15.parquet" if source == "unsw"
                   else "cicids2017/cicids2017.parquet")
    split_col = "split" if source == "unsw" else f"split_{split_scheme}"
    need_ddos = source == "cic" and split_scheme == "strat"

    # 1) conteos de train de la corrida β†’ candidates (D12) + Ο‰ (Β§3.5)
    count_cols = [split_col, "label_family"] + (["day"] if need_ddos else [])
    tbl = pq.read_table(path, columns=count_cols)
    tr = np.asarray(tbl.column(split_col).to_pylist()) == "train"
    fam_all = np.asarray(tbl.column("label_family").to_pylist())
    if exclude_family is not None:
        tr = tr & (fam_all != exclude_family)
    counts = {f: int((fam_all[tr] == f).sum()) for f in _registry(source)["native_families"]}
    candidates = _frozen_candidates(source, counts)
    omega = torch.tensor(class_weights([counts[c] for c in candidates]))

    # 2) splits (LOAO: la familia sale de train Y val; test completo Β§6.7)
    x_tr, txt_tr, lab_tr, _ = _load_network_split(path, source, split_col, "train", candidates, need_ddos, exclude_family)
    ddos_counts = None
    if need_ddos:
        sub = lab_tr.ddos_mask
        ddos_counts = [int((lab_tr.y_ddos[sub] == i).sum())
                       for i in range(len(DDOS_CANDIDATES))]
        if any(c == 0 for c in ddos_counts):
            # fold LOAO dos_ddos: las clases de ataque del vector ddos viven
            # en la familia excluida (el subset conserva solo BENIGN) β†’ sin
            # head ddos en esta corrida (E2 evalΓΊa noul/choice en la familia)
            need_ddos = False
            ddos_counts = None
            lab_tr = Labels(lab_tr.y_bin, lab_tr.y_fam, lab_tr.y_sev, None, None)
    x_va, txt_va, lab_va, va_fams = _load_network_split(path, source, split_col, "val", candidates, need_ddos, exclude_family)
    x_te, txt_te, lab_te, te_fams = _load_network_split(path, source, split_col, "test", candidates, need_ddos, None)

    # 3) vocab (train de la corrida, Β§4.2) + Ο† (train)
    vocab = None
    if source == "unsw":
        vocab = {k: TextVocab.fit(k, txt_tr[k]) for k in ("proto", "service", "state")}
    stage = FeatureStage.fit(x_tr.astype(np.float64))

    u_tr, _ = stage.transform(x_tr)
    u_va, _ = stage.transform(x_va)
    u_te, _ = stage.transform(x_te)
    train_txt = {k: torch.tensor(vocab[k].encode(txt_tr[k]), dtype=torch.int64) for k in vocab} if vocab else None
    val_txt = {k: torch.tensor(vocab[k].encode(txt_va[k]), dtype=torch.int64) for k in vocab} if vocab else None
    test_txt = {k: torch.tensor(vocab[k].encode(txt_te[k]), dtype=torch.int64) for k in vocab} if vocab else None

    ddos_candidates = list(DDOS_CANDIDATES) if need_ddos else None
    omega_ddos = None
    if need_ddos:
        assert all(c > 0 for c in ddos_counts), f"ddos class sin filas: {ddos_counts}"
        omega_ddos = torch.tensor(class_weights(ddos_counts))

    heads = HeadSpec(n_candidates=len(candidates), ddos_candidates=4 if need_ddos else 0)
    return RunData(
        source=source, scale=scale, split_scheme=split_scheme,
        candidates=candidates, ddos_candidates=ddos_candidates,
        omega=omega, omega_ddos=omega_ddos, vocab=vocab, feature_stage=stage,
        heads=heads,
        train=torch.tensor(u_tr), train_txt=train_txt, train_labels=lab_tr,
        val=torch.tensor(u_va), val_txt=val_txt, val_labels=lab_va,
        test=torch.tensor(u_te), test_txt=test_txt, test_labels=lab_te,
        val_fam_names=va_fams, test_fam_names=te_fams,
        meta={"train_counts": {c: counts[c] for c in candidates},
              "exclusion": {"exclude": exclude_family} if exclude_family else {}},
    )


# ── EMBER: streaming por shards ────────────────────────────────────────
def ember_shards(subset: str) -> list[Path]:
    d = DATA / "ember2024"
    if subset == "train":
        return [d / f"part_{i:02d}_train.parquet" for i in range(46)]
    if subset == "val":
        return [d / f"part_{i:02d}_val.parquet" for i in range(46, 52)]
    return [d / f"part_{i:02d}_test.parquet" for i in range(52, 64)]


def _ember_labels(candidates: list[str]):
    imap = _idx_map(candidates)

    def decode(tbl) -> tuple[np.ndarray, torch.Tensor, torch.Tensor]:
        fam = tbl.column("label_family").to_pylist()
        # A5-EMBER: 'unknown' fuera del candidate set β†’ sentinela -1 (la
        # mΓ‘scara de _batch_losses la saca del CE de choice)
        y_fam = torch.tensor([imap.get(f, -1) for f in fam], dtype=torch.int64)
        y_bin = torch.tensor(tbl.column("label_binary").to_pylist(), dtype=torch.int64)
        return y_fam, y_bin
    return decode


def build_ember_run(scale: str, exclude_family: str | None = None) -> RunData:
    tr_shards, va_shards, te_shards = ember_shards("train"), ember_shards("val"), ember_shards("test")

    # 1) conteos de train (una pasada ligera por shard, solo label_family)
    counts = {f: 0 for f in _registry("ember")["native_families"]}
    for p in tr_shards:
        for f in pq.read_table(p, columns=["label_family"]).column("label_family").to_pylist():
            counts[f] += 1
    if exclude_family is not None:
        counts[exclude_family] = 0
    candidates = _frozen_candidates("ember2024_v1", counts)
    omega = torch.tensor(class_weights([counts[c] for c in candidates]))

    # 2) Ο† con submuestra uniforme de train (decisiΓ³n documentada arriba)
    rng = np.random.default_rng(42)
    sample_chunks: list[np.ndarray] = []
    for p in tr_shards:
        x = _read_x_num(p).astype(np.float32)
        take = rng.choice(x.shape[0], size=EMBER_STATS_ROWS_PER_SHARD, replace=False)
        sample_chunks.append(x[take].astype(np.float64))
        del x
    stage = FeatureStage.fit(np.concatenate(sample_chunks))
    del sample_chunks

    return RunData(
        source="ember2024_v1", scale=scale, split_scheme="temporal",
        candidates=candidates, ddos_candidates=None,
        omega=omega, omega_ddos=None, vocab=None, feature_stage=stage,
        heads=HeadSpec(n_candidates=len(candidates), has_score=False),
        train=torch.empty(0), train_txt=None,
        train_labels=Labels(torch.empty(0, dtype=torch.int64), torch.empty(0, dtype=torch.int64), None, None, None),
        val=torch.empty(0), val_txt=None,
        val_labels=Labels(torch.empty(0, dtype=torch.int64), torch.empty(0, dtype=torch.int64), None, None, None),
        test=None, test_txt=None, test_labels=None,
        ember_train_shards=tr_shards, ember_val_shards=va_shards, ember_test_shards=te_shards,
        meta={"train_counts": {c: counts[c] for c in candidates}, "exclusion": {"exclude": exclude_family} if exclude_family else {}},
    )


class EmberBatcher:
    """Itera batches de train (o evalΓΊa val) con streaming por shards.

    Train: permutaciΓ³n de shards seed 42+epoch β†’ decode β†’ shuffle intra-shard
    β†’ batches con drop_last. Prefetch de 1 shard en thread (Β§6.2).
    Eval (val/test/challenge): shards en orden fijo, batches sin drop_last,
    shuffle off.
    """

    def __init__(self, run: RunData) -> None:
        self.run = run
        self.stage = run.feature_stage
        self._decode = _ember_labels(run.candidates)

    def _load_shard(self, path: Path) -> tuple[np.ndarray, torch.Tensor, torch.Tensor]:
        tbl = pq.read_table(path, columns=["x_num", "label_family", "label_binary"])
        x = _read_x_num(path)  # float64 para Ο†
        u, _ = self.stage.transform(x)
        del x
        y_fam, y_bin = self._decode(tbl)
        return u, y_fam, y_bin

    def _iter_shard_batches(self, path: Path, batch_size: int, rng: np.random.Generator | None):
        u, y_fam, y_bin = self._load_shard(path)
        n = u.shape[0]
        order = rng.permutation(n) if rng is not None else np.arange(n)
        n_batches = n // batch_size if rng is not None else int(np.ceil(n / batch_size))
        for b in range(n_batches):
            idx = order[b * batch_size:(b + 1) * batch_size]
            yield (torch.tensor(u[idx]), y_fam[idx], y_bin[idx])

    def _stream(self, shards: list[Path], batch_size: int, rng: np.random.Generator | None):
        """Genera batches; con prefetch de 1 shard (thread) si rng (train)."""
        if rng is None:  # eval: secuencial simple
            for p in shards:
                yield from self._iter_shard_batches(p, batch_size, None)
            return

        # train: productor en thread descarga el shard i+1 mientras se
        # consumen los batches del shard i (prefetch=1, Β§6.2).
        import queue

        q: queue.Queue = queue.Queue(maxsize=1)
        exc: list[BaseException] = []

        def producer() -> None:
            try:
                for p in shards:
                    q.put(self._load_shard(p))
            except BaseException as e:
                exc.append(e)
            finally:
                q.put(None)

        t = threading.Thread(target=producer, daemon=True)
        t.start()
        while True:
            item = q.get()
            if item is None:
                break
            u, y_fam, y_bin = item
            n = u.shape[0]
            order = rng.permutation(n)
            for b in range(n // batch_size):  # drop_last
                idx = order[b * batch_size:(b + 1) * batch_size]
                yield (torch.tensor(u[idx]), y_fam[idx], y_bin[idx])
        if exc:
            raise exc[0]

    def train_batches(self, epoch: int, batch_size: int):
        rng = np.random.default_rng(42 + epoch)
        return self._stream(self.run.ember_train_shards, batch_size, rng)

    def eval_batches(self, subset: str, batch_size: int):
        # "train" = streaming en orden (sin shuffle/drop_last): para
        # materializar features en los baselines tabulares
        shards = {"train": self.run.ember_train_shards,
                  "val": self.run.ember_val_shards,
                  "test": self.run.ember_test_shards}[subset]
        return self._stream(shards, batch_size, None)


# ── fΓ‘brica ────────────────────────────────────────────────────────────
def build_run(source: str, scale: str, split_scheme: str,
              exclude_family: str | None = None) -> RunData:
    if source == "unsw":
        return build_network_run("unsw", scale, "oficial", exclude_family)
    if source == "cic":
        return build_network_run("cic", scale, split_scheme, exclude_family)
    if source == "ember":
        return build_ember_run(scale, exclude_family)
    raise ValueError(source)