ids2eval 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. ids2eval/__init__.py +0 -0
  2. ids2eval/__main__.py +4 -0
  3. ids2eval/_json_utils.py +24 -0
  4. ids2eval/audit/__init__.py +239 -0
  5. ids2eval/audit/_auc.py +48 -0
  6. ids2eval/audit/_by_class.py +29 -0
  7. ids2eval/audit/_checkpoint.py +74 -0
  8. ids2eval/audit/_fit_score.py +40 -0
  9. ids2eval/audit/_materiality.py +28 -0
  10. ids2eval/audit/_status.py +15 -0
  11. ids2eval/audit/_time.py +14 -0
  12. ids2eval/audit/artifact_sensitivity.py +88 -0
  13. ids2eval/audit/class_distribution.py +57 -0
  14. ids2eval/audit/cross_capture_matrix.py +115 -0
  15. ids2eval/audit/cross_dataset_drift.py +74 -0
  16. ids2eval/audit/data_integrity.py +67 -0
  17. ids2eval/audit/dedup.py +67 -0
  18. ids2eval/audit/feature_auc_ranking.py +125 -0
  19. ids2eval/audit/feature_category_ablation.py +98 -0
  20. ids2eval/audit/flow_group_leakage.py +66 -0
  21. ids2eval/audit/homogeneity.py +160 -0
  22. ids2eval/audit/identity_columns.py +171 -0
  23. ids2eval/audit/known_issues.py +144 -0
  24. ids2eval/audit/label_conflict.py +92 -0
  25. ids2eval/audit/leakage.py +99 -0
  26. ids2eval/audit/near_duplicate_class.py +87 -0
  27. ids2eval/audit/one_rule.py +82 -0
  28. ids2eval/audit/port_protocol_shortcut.py +75 -0
  29. ids2eval/audit/repeated_seed_falsification.py +182 -0
  30. ids2eval/audit/resplit.py +92 -0
  31. ids2eval/audit/result_robustness.py +154 -0
  32. ids2eval/audit/row_order_leakage.py +86 -0
  33. ids2eval/audit/scenario_holdout.py +115 -0
  34. ids2eval/audit/schema_fingerprint.py +52 -0
  35. ids2eval/audit/seed_sensitivity.py +69 -0
  36. ids2eval/audit/synthetic_realism.py +73 -0
  37. ids2eval/audit/temporal_leakage.py +58 -0
  38. ids2eval/audit/temporal_realism.py +81 -0
  39. ids2eval/cli.py +284 -0
  40. ids2eval/config.py +362 -0
  41. ids2eval/dashboard/__init__.py +1 -0
  42. ids2eval/dashboard/__main__.py +3 -0
  43. ids2eval/dashboard/server.py +527 -0
  44. ids2eval/dashboard/static/app.js +1064 -0
  45. ids2eval/dashboard/static/charts.js +366 -0
  46. ids2eval/dashboard/static/index.html +33 -0
  47. ids2eval/dashboard/static/style.css +293 -0
  48. ids2eval/data/__init__.py +3 -0
  49. ids2eval/data/cache.py +99 -0
  50. ids2eval/data/chunked_io.py +387 -0
  51. ids2eval/data/dataset.py +208 -0
  52. ids2eval/data/features.py +95 -0
  53. ids2eval/data/label_grouping.py +38 -0
  54. ids2eval/modeling/__init__.py +3 -0
  55. ids2eval/modeling/benchmark.py +360 -0
  56. ids2eval/modeling/classifiers.py +145 -0
  57. ids2eval/modeling/preprocessing.py +73 -0
  58. ids2eval/reporting/__init__.py +2 -0
  59. ids2eval/reporting/cite.py +57 -0
  60. ids2eval/reporting/compare_datasets.py +103 -0
  61. ids2eval/reporting/drift.py +80 -0
  62. ids2eval/reporting/intervention_manifest.py +83 -0
  63. ids2eval/reporting/recommend.py +402 -0
  64. ids2eval/reporting/run_manager.py +184 -0
  65. ids2eval/reporting/scorecard.py +875 -0
  66. ids2eval/reporting/scorecard_plot.py +162 -0
  67. ids2eval/version_info.py +36 -0
  68. ids2eval-0.2.2.dist-info/METADATA +119 -0
  69. ids2eval-0.2.2.dist-info/RECORD +73 -0
  70. ids2eval-0.2.2.dist-info/WHEEL +5 -0
  71. ids2eval-0.2.2.dist-info/entry_points.txt +4 -0
  72. ids2eval-0.2.2.dist-info/licenses/LICENSE +661 -0
  73. ids2eval-0.2.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,387 @@
1
+ """Chunked CSV/Parquet reading with optional reservoir sampling.
2
+
3
+ dataset.raw_files/train_file/test_file accept, per file: plain CSV,
4
+ CSV with a compression suffix pandas infers from the name (.csv.gz,
5
+ .csv.bz2, .csv.xz, and .csv.zip provided the zip holds exactly one
6
+ member - pandas' own read_csv handles all of these natively, chunked
7
+ reading included, nothing in this module is compression-aware), or
8
+ .parquet (dispatched to pyarrow, already a hard dependency).
9
+
10
+ A multi-member zip (several files in one archive, the common shape of
11
+ a real dataset's official download - a features/schema reference, a
12
+ pre-split train/test pair, several raw per-scenario capture files,
13
+ an event log, ...) has no single obvious member to read, so naming
14
+ one explicitly is required: "archive.zip::member_name.csv" (a literal
15
+ "::" separates the archive path from the member's name inside it,
16
+ picked because neither a real filesystem path nor a zip member name
17
+ uses "::"). CSV members stream chunk by chunk exactly like a plain
18
+ file; Parquet members are read whole into memory first, since
19
+ pyarrow's reader needs a seekable source and a zip member isn't one -
20
+ fine for a single reference file, not a substitute for chunk_size on
21
+ something too big to hold in RAM at all.
22
+
23
+ Not supported: .rar, or a zip member that is itself a nested archive.
24
+ A rar reader needs an external unrar/7z binary on PATH with no
25
+ reliable cross-platform story; extract it yourself first and point at
26
+ the CSV/Parquet file(s) inside, or name a member with "::" if it's
27
+ zipped instead.
28
+
29
+ A CSV with no header row at all (a real, recurring shape for an
30
+ official download - NSL-KDD's original KDDTrain+.txt/KDDTest+.txt have
31
+ none) is handled by dataset.column_names: when set, every CSV read
32
+ treats row 0 as data and assigns these names positionally, instead of
33
+ inferring a header from the first row. Ignored for Parquet, which
34
+ already carries its own column names in the file. A column_names list
35
+ built for the wrong file raises a clear error naming the mismatch
36
+ rather than pandas' own cryptic "Expected N fields... saw M" - a real,
37
+ reproduced case: UNSW-NB15's four raw capture files have 49 columns,
38
+ while its separate pre-split training-set/testing-set files have 45.
39
+
40
+ Two independent knobs (dataset.chunk_size / dataset.max_rows):
41
+
42
+ - chunk_size bounds memory during the read/downcast phase only, by
43
+ reading each file chunk_size rows at a time and downcasting float64
44
+ columns to float32 immediately (same rationale as the IDS project's
45
+ loader: CICFlowMeter-style features are counts/durations/rates that
46
+ don't need float64 precision). On its own this does NOT prevent OOM
47
+ on a dataset bigger than RAM, since every downstream step (audit
48
+ checks, sklearn/imblearn fitting) still needs one consolidated
49
+ in-memory array.
50
+
51
+ - max_rows is what actually bounds the final in-memory size: a uniform
52
+ reservoir sample (Algorithm R) across the whole stream of rows, so
53
+ the retained dataset never exceeds max_rows regardless of source
54
+ file size. This is a statistical sample, not the exact full dataset.
55
+ It operates at the row level: if dataset.split_mode is "grouped",
56
+ sampling may fragment session groups across the sample boundary;
57
+ that's a documented limitation, not a bug fix candidate for v1.
58
+ """
59
+
60
+ from __future__ import annotations
61
+
62
+ import logging
63
+ from collections.abc import Iterator
64
+
65
+ import numpy as np
66
+ import pandas as pd
67
+
68
+ logger = logging.getLogger(__name__)
69
+
70
+
71
+ def _downcast_chunk(df: pd.DataFrame) -> pd.DataFrame:
72
+ float_cols = df.select_dtypes(include=["float64"]).columns
73
+ if len(float_cols):
74
+ df[float_cols] = df[float_cols].astype("float32")
75
+ return df
76
+
77
+
78
+ def _drop_embedded_header_rows(df: pd.DataFrame) -> pd.DataFrame:
79
+ """Drop rows that are the column header duplicated as literal data.
80
+
81
+ A known, real quirk in multi-file exports (e.g. some CIC-IDS2018 raw
82
+ day-files): the header row gets embedded partway through the file as
83
+ an ordinary data row. Left in, it makes some chunks infer a
84
+ different (object) dtype for an otherwise-numeric column than other
85
+ chunks do, which crashes reservoir_sample's cross-chunk assignment
86
+ with a LossySetitemError - a real failure hit against real data, not
87
+ a hypothetical. Schema-agnostic: flags a row only when EVERY column's
88
+ value equals that column's own name, so it can't false-positive on
89
+ a legitimate row that happens to match one header string.
90
+ """
91
+ mask = (df.astype(str) == df.columns.astype(str)).all(axis=1)
92
+ if mask.any():
93
+ logger.warning("Dropped %d embedded header row(s) found as literal data", int(mask.sum()))
94
+ df = df.loc[~mask]
95
+ return df
96
+
97
+
98
+ def _drop_blank_rows(df: pd.DataFrame) -> pd.DataFrame:
99
+ """Drop rows where every single column is missing.
100
+
101
+ A real quirk hit against CIC-IDS2017's official GeneratedLabelledFlows
102
+ release: its Thursday-WorkingHours-Morning-WebAttacks file has 170,366
103
+ real flow rows followed by 288,602 entirely blank rows (verified by
104
+ direct inspection of the raw bytes: literal comma-only lines with no
105
+ values at all) appended to the end of the file, not truncated data or a
106
+ header/schema issue. A row this empty carries no label and no features,
107
+ so it can't be used for anything downstream; left in, it crashes the
108
+ stratified train/test split with "Input contains NaN" before any audit
109
+ check gets a chance to report on it. Only drops a row that is blank in
110
+ EVERY column, not merely missing its label, since a row with real
111
+ feature values but no label is a different, more debatable case that
112
+ this function deliberately leaves for data_integrity_check to report
113
+ rather than silently discarding.
114
+ """
115
+ mask = df.isna().all(axis=1)
116
+ if mask.any():
117
+ logger.warning("Dropped %d fully blank row(s) (every column missing)", int(mask.sum()))
118
+ df = df.loc[~mask]
119
+ return df
120
+
121
+
122
+ def _is_parquet(name: str) -> bool:
123
+ return str(name).lower().endswith(".parquet")
124
+
125
+
126
+ def _iter_parquet_chunks(source, chunk_size: int | None, column_names: list[str] | None = None) -> Iterator[pd.DataFrame]:
127
+ """source is a path (str) or a seekable file-like object (an in-memory
128
+ zip member). pyarrow's reader needs random access for the footer, so a
129
+ zip member is read fully into a BytesIO buffer by the caller first.
130
+
131
+ column_names is ignored here: Parquet always carries its own column
132
+ names in the file itself, unlike a headerless CSV. Logged once so a
133
+ dataset.column_names set for a mixed CSV+Parquet raw_files list isn't
134
+ silently dropped without a trace.
135
+ """
136
+ import pyarrow.parquet as pq
137
+
138
+ if column_names:
139
+ logger.warning("dataset.column_names is set but %s is Parquet, which already has its "
140
+ "own column names; ignoring dataset.column_names for this file", source)
141
+ parquet_file = pq.ParquetFile(source)
142
+ if chunk_size:
143
+ for batch in parquet_file.iter_batches(batch_size=chunk_size):
144
+ yield batch.to_pandas()
145
+ else:
146
+ yield parquet_file.read().to_pandas()
147
+
148
+
149
+ def _iter_csv_chunks(source, chunk_size: int | None, column_names: list[str] | None = None) -> Iterator[pd.DataFrame]:
150
+ """source is a path (str) or any readable file-like object (a zip
151
+ member's stream, read sequentially - pandas never seeks it).
152
+
153
+ column_names, if given, means the file itself has no header row: every
154
+ row is data, and these names are assigned positionally (dataset.
155
+ column_names in the config, for a CSV export with the header stripped
156
+ or never included, a real, recurring shape of an official NIDS
157
+ download - NSL-KDD's own original KDDTrain+.txt/KDDTest+.txt are
158
+ exactly this).
159
+
160
+ Tries UTF-8 first and falls back to Windows-1252 for a path (not a zip
161
+ member stream) that isn't valid UTF-8: a real, reproduced case is
162
+ CIC-IDS2017's official GeneratedLabelledFlows release, whose Thursday
163
+ WebAttacks file has a Windows-1252 en-dash byte (0x96) in a "Web Attack
164
+ - Brute Force"-style label, otherwise plain ASCII. Each attempt is fully
165
+ materialized into a list before anything is yielded, specifically so a
166
+ decode error partway through a chunked read can't leave earlier chunks
167
+ already handed to the caller when the file is re-read from the start
168
+ under the fallback encoding, which would otherwise silently duplicate
169
+ those rows. A zip member's stream can't be safely re-read from the
170
+ start once partially consumed, so only a plain path gets the retry.
171
+ """
172
+ kwargs = {"header": None, "names": column_names} if column_names else {}
173
+ encodings = ("utf-8", "cp1252") if isinstance(source, str) else ("utf-8",)
174
+ for attempt, encoding in enumerate(encodings):
175
+ try:
176
+ read_kwargs = {**kwargs, "encoding": encoding}
177
+ reader = pd.read_csv(source, chunksize=chunk_size, low_memory=False, **read_kwargs) if chunk_size \
178
+ else [pd.read_csv(source, low_memory=False, **read_kwargs)]
179
+ chunks = list(reader)
180
+ except UnicodeDecodeError:
181
+ if attempt + 1 < len(encodings):
182
+ continue
183
+ raise
184
+ except pd.errors.ParserError as e:
185
+ if not column_names:
186
+ raise
187
+ # pandas' own message ("Expected N fields... saw M") already names both
188
+ # counts, but gives no hint that dataset.column_names is the likely
189
+ # cause - a real, reproduced case: UNSW-NB15's four raw capture files
190
+ # have 49 columns, while its separate pre-split training-set/
191
+ # testing-set files have 45; pointing column_names built for one at
192
+ # the other produces exactly this error.
193
+ raise ValueError(
194
+ f"{source}: dataset.column_names has {len(column_names)} names, but this file's "
195
+ f"rows don't have that many fields ({e}). Check that column_names lists every "
196
+ "column in this exact file's own order - a multi-file raw release sometimes has a "
197
+ "different column count per file."
198
+ ) from e
199
+ else:
200
+ if attempt > 0:
201
+ logger.warning(
202
+ "%s: not valid UTF-8, read successfully as %s instead (a common source "
203
+ "encoding for these datasets' Windows-based extraction tools)",
204
+ source, encoding,
205
+ )
206
+ yield from chunks
207
+ return
208
+
209
+
210
+ def _iter_zip_member_chunks(
211
+ archive_path: str, member: str, chunk_size: int | None, column_names: list[str] | None = None
212
+ ) -> Iterator[pd.DataFrame]:
213
+ import io
214
+ import zipfile
215
+
216
+ with zipfile.ZipFile(archive_path) as archive:
217
+ if _is_parquet(member):
218
+ yield from _iter_parquet_chunks(io.BytesIO(archive.read(member)), chunk_size, column_names)
219
+ return
220
+ with archive.open(member) as member_file:
221
+ yield from _iter_csv_chunks(member_file, chunk_size, column_names)
222
+
223
+
224
+ def _iter_raw_chunks(path: str, chunk_size: int | None, column_names: list[str] | None = None) -> Iterator[pd.DataFrame]:
225
+ """One file's (or one zip member's) chunks, dispatched by extension.
226
+
227
+ "archive.zip::member.csv" reads just that member; see this module's
228
+ docstring for why one has to be named explicitly. Otherwise: CSV
229
+ (plain or with a compression suffix pandas already infers from the
230
+ name - .gz, .bz2, .zip [single member], .xz) via pd.read_csv,
231
+ chunksize supported natively. Parquet via pyarrow's own batched
232
+ reader, since pd.read_parquet has no chunksize equivalent; already
233
+ a hard dependency (used for output.format: parquet), so this adds
234
+ no new package to install.
235
+ """
236
+ if "::" in path:
237
+ archive_path, _, member = path.partition("::")
238
+ yield from _iter_zip_member_chunks(archive_path, member, chunk_size, column_names)
239
+ elif _is_parquet(path):
240
+ yield from _iter_parquet_chunks(path, chunk_size, column_names)
241
+ else:
242
+ yield from _iter_csv_chunks(path, chunk_size, column_names)
243
+
244
+
245
+ def iter_chunks(
246
+ paths: list[str], chunk_size: int | None, column_names: list[str] | None = None
247
+ ) -> Iterator[pd.DataFrame]:
248
+ """Yield chunks with a dtype schema stable across the whole stream.
249
+
250
+ pandas infers dtype independently per chunk of a chunked CSV read -
251
+ a column that is int64 in one chunk can come out object in another
252
+ purely from chunk-local quirks (a stray NaN, an unusual value only
253
+ present in that chunk), with no embedded-header-row or other
254
+ "real" data-quality issue involved. Real failure hit against
255
+ CIC-IDS2018: this crashed reservoir_sample's cross-chunk assignment
256
+ with a LossySetitemError. Fix: lock in which columns are numeric
257
+ from the first chunk seen, then force every later chunk (including
258
+ chunks from later files, for a multi-file raw_files list) through
259
+ pd.to_numeric(errors="coerce") on exactly those columns, so the
260
+ dtype is stable across the entire stream regardless of per-chunk
261
+ inference quirks. Matches the IDS project's own loader, which does
262
+ the same coercion for the same reason. Known limitation: trusts the
263
+ first chunk's inference: a column that's genuinely numeric but
264
+ happens to look non-numeric in just the first chunk would be
265
+ (wrongly) treated as categorical for the rest of the stream.
266
+
267
+ Columns locked as non-numeric from the first chunk get the mirror-image
268
+ treatment: forced to string dtype on every later chunk. Real failure hit
269
+ against CICDDoS2019: a column mostly empty/placeholder values (inferred
270
+ object dtype in the first chunk) had a later chunk where every value
271
+ happened to parse as a plain integer, so the concatenated column ended
272
+ up with genuinely mixed str/int Python objects in one array - pyarrow's
273
+ to_parquet (via cache.save) rejects that outright ("Expected bytes, got
274
+ a 'int' object"). Without this, only numeric-locked columns were immune
275
+ to the equivalent problem in the other direction.
276
+ """
277
+ numeric_columns = None
278
+ object_columns = None
279
+ reference_columns = None
280
+ for path in paths:
281
+ for chunk in _iter_raw_chunks(path, chunk_size, column_names):
282
+ chunk.columns = chunk.columns.str.strip()
283
+ chunk = _drop_embedded_header_rows(chunk)
284
+ chunk = _drop_blank_rows(chunk)
285
+
286
+ if reference_columns is None:
287
+ reference_columns = chunk.columns
288
+ elif not chunk.columns.equals(reference_columns):
289
+ # Real quirk hit against CIC-IDS2018: one raw day-file out of
290
+ # ten carries 4 extra columns (Flow ID/Src IP/Src Port/Dst IP)
291
+ # no other file has. Reservoir_sample's positional cross-chunk
292
+ # assignment requires every chunk to share one fixed column
293
+ # set, so align to whichever schema was seen first - extra
294
+ # columns are dropped, missing ones filled NaN - rather than
295
+ # crashing or silently misaligning columns by position.
296
+ extra = set(chunk.columns) - set(reference_columns)
297
+ missing = set(reference_columns) - set(chunk.columns)
298
+ logger.warning(
299
+ "%s: column schema differs from the first file read (extra: %s, "
300
+ "missing: %s) - aligning to the first file's schema",
301
+ path, sorted(extra), sorted(missing),
302
+ )
303
+ chunk = chunk.reindex(columns=reference_columns)
304
+
305
+ if numeric_columns is None:
306
+ numeric_columns = chunk.select_dtypes(include=["number"]).columns
307
+ object_columns = chunk.columns.difference(numeric_columns)
308
+ for col in numeric_columns:
309
+ if col in chunk.columns:
310
+ # Always land on float64 here, never int64: an int64
311
+ # column in one chunk (e.g. a 0/1 flag column with no
312
+ # NaN in that particular chunk) and a float64 version
313
+ # of the same column in another (NaN introduced by
314
+ # coercion, or genuinely fractional values) hits the
315
+ # same LossySetitemError this whole schema-locking
316
+ # exists to prevent - real failure, CIC-IDS2018.
317
+ chunk[col] = pd.to_numeric(chunk[col], errors="coerce").astype("float64")
318
+ for col in object_columns:
319
+ if col in chunk.columns and not pd.api.types.is_object_dtype(chunk[col]):
320
+ # Mirror image of the numeric case above: this column looked
321
+ # non-numeric in the first chunk, but this later chunk's values
322
+ # all happen to parse as a different dtype (e.g. every value here
323
+ # is a plain integer) - force back to string, preserving real NaN
324
+ # rather than stringifying it to the literal text "nan".
325
+ chunk[col] = chunk[col].where(chunk[col].isna(), chunk[col].astype(str))
326
+ yield _downcast_chunk(chunk)
327
+
328
+
329
+ def reservoir_sample(chunks: Iterator[pd.DataFrame], max_rows: int, seed: int = 0) -> tuple[pd.DataFrame, int]:
330
+ """Uniform reservoir sample of at most max_rows rows across all chunks.
331
+
332
+ Vectorized batched Algorithm R: within a chunk, later rows correctly
333
+ overwrite earlier rows at the same reservoir slot (pandas/numpy
334
+ fancy-indexing assignment resolves duplicate target positions by
335
+ keeping the last one in source order), matching the sequential
336
+ semantics of the classic single-item algorithm.
337
+ """
338
+ rng = np.random.default_rng(seed)
339
+ reservoir: pd.DataFrame | None = None
340
+ n_seen = 0
341
+
342
+ for chunk in chunks:
343
+ if reservoir is None:
344
+ reservoir = chunk.iloc[0:0].copy()
345
+
346
+ if n_seen < max_rows:
347
+ take = min(max_rows - n_seen, len(chunk))
348
+ reservoir = pd.concat([reservoir, chunk.iloc[:take]], ignore_index=True)
349
+ chunk = chunk.iloc[take:]
350
+ n_seen += take
351
+
352
+ m = len(chunk)
353
+ if m > 0:
354
+ global_idx = np.arange(n_seen, n_seen + m)
355
+ j = rng.integers(0, global_idx + 1) # per-row draw in [0, i], inclusive
356
+ replace_mask = j < max_rows
357
+ if replace_mask.any():
358
+ reservoir.iloc[j[replace_mask]] = chunk.iloc[np.flatnonzero(replace_mask)].values
359
+ n_seen += m
360
+
361
+ if reservoir is None:
362
+ raise ValueError("No data read, chunk source was empty")
363
+ return reservoir, n_seen
364
+
365
+
366
+ def load_file(
367
+ path: str, chunk_size: int | None, max_rows: int | None, seed: int = 0,
368
+ column_names: list[str] | None = None,
369
+ ) -> pd.DataFrame:
370
+ chunks = iter_chunks([path], chunk_size, column_names)
371
+ if max_rows:
372
+ df, n_seen = reservoir_sample(chunks, max_rows, seed)
373
+ logger.info("Reservoir-sampled %s: %d rows seen -> %d kept (dataset.max_rows)", path, n_seen, len(df))
374
+ return df
375
+ return pd.concat(list(chunks), ignore_index=True)
376
+
377
+
378
+ def load_files_combined(
379
+ paths: list[str], chunk_size: int | None, max_rows: int | None, seed: int = 0,
380
+ column_names: list[str] | None = None,
381
+ ) -> pd.DataFrame:
382
+ chunks = iter_chunks(paths, chunk_size, column_names)
383
+ if max_rows:
384
+ df, n_seen = reservoir_sample(chunks, max_rows, seed)
385
+ logger.info("Reservoir-sampled %d files: %d rows seen -> %d kept (dataset.max_rows)", len(paths), n_seen, len(df))
386
+ return df
387
+ return pd.concat(list(chunks), ignore_index=True)
@@ -0,0 +1,208 @@
1
+ """Load raw or pre-split IDS data and produce a train/test split.
2
+
3
+ Ported and generalized from the IDS project's data/loader.py and
4
+ data/preprocessor.py._deduplicate, same session-grouped-split and
5
+ feature-space-dedup logic, but driven by config instead of hardcoded
6
+ per-dataset column names.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+
13
+ import pandas as pd
14
+ from sklearn.model_selection import StratifiedGroupKFold, train_test_split
15
+
16
+ from . import chunked_io
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def load_raw_combined(dataset_cfg: dict, seed: int = 0) -> pd.DataFrame:
22
+ """Load and concatenate dataset.raw_files, before any split is applied.
23
+
24
+ Honors dataset.chunk_size/max_rows, see chunked_io module docstring
25
+ for what each actually bounds. seed drives reservoir_sample's
26
+ randomness when max_rows is set.
27
+ """
28
+ if dataset_cfg["max_rows"] and dataset_cfg["group_columns"]:
29
+ logger.warning(
30
+ "dataset.max_rows reservoir sampling is row-level, not group-aware. "
31
+ "session groups (dataset.group_columns) may be fragmented across the "
32
+ "sample boundary before any grouped split is applied."
33
+ )
34
+ return chunked_io.load_files_combined(
35
+ dataset_cfg["raw_files"], dataset_cfg["chunk_size"], dataset_cfg["max_rows"], seed,
36
+ dataset_cfg["column_names"],
37
+ )
38
+
39
+
40
+ def load_split(cfg: dict) -> tuple[pd.DataFrame, pd.DataFrame]:
41
+ """Return (train_df, test_df), pre-split if configured, else loaded+split."""
42
+ dataset_cfg = cfg["dataset"]
43
+ seed = cfg["random_seed"]
44
+ if dataset_cfg["train_file"] and dataset_cfg["test_file"]:
45
+ chunk_size, max_rows = dataset_cfg["chunk_size"], dataset_cfg["max_rows"]
46
+ column_names = dataset_cfg["column_names"]
47
+ train_df = chunked_io.load_file(dataset_cfg["train_file"], chunk_size, max_rows, seed, column_names)
48
+ test_df = chunked_io.load_file(dataset_cfg["test_file"], chunk_size, max_rows, seed, column_names)
49
+ return train_df, test_df
50
+
51
+ combined = load_raw_combined(dataset_cfg, seed)
52
+ label_col = cfg["schema"]["label_column"]
53
+ if dataset_cfg["split_mode"] == "grouped":
54
+ return _grouped_split(combined, label_col, dataset_cfg, seed)
55
+ return _random_split(combined, label_col, dataset_cfg, seed)
56
+
57
+
58
+ def _random_split(
59
+ df: pd.DataFrame, label_col: str, dataset_cfg: dict, seed: int = 0
60
+ ) -> tuple[pd.DataFrame, pd.DataFrame]:
61
+ train, test = train_test_split(
62
+ df,
63
+ test_size=1.0 - dataset_cfg["split_ratio"],
64
+ random_state=seed,
65
+ stratify=df[label_col],
66
+ )
67
+ return train.reset_index(drop=True), test.reset_index(drop=True)
68
+
69
+
70
+ def _grouped_split(
71
+ df: pd.DataFrame, label_col: str, dataset_cfg: dict, seed: int = 0
72
+ ) -> tuple[pd.DataFrame, pd.DataFrame]:
73
+ """Session/time-grouped split via StratifiedGroupKFold.
74
+
75
+ Keeps every group_columns combination on one side of the split, so
76
+ session-correlated leakage across the train/test boundary is
77
+ structurally impossible. See the resplit-falsification audit check,
78
+ which uses this same split as its counterfactual.
79
+ """
80
+ group_cols = [c for c in dataset_cfg["group_columns"] if c in df.columns]
81
+ if not group_cols:
82
+ raise ValueError(
83
+ f"None of dataset.group_columns {dataset_cfg['group_columns']} "
84
+ f"are present in the loaded data"
85
+ )
86
+ # Vectorized string concatenation, not df[group_cols].astype(str).agg("|".join,
87
+ # axis=1): that row-wise .agg runs a Python-level join once per row, which at
88
+ # full scale (hundreds of millions of rows) becomes the dominant memory cost
89
+ # in the whole audit - a real failure this was built to avoid, not a
90
+ # theoretical one.
91
+ groups = df[group_cols[0]].astype(str)
92
+ for col in group_cols[1:]:
93
+ groups = groups + "|" + df[col].astype(str)
94
+
95
+ n_splits = round(1.0 / (1.0 - dataset_cfg["split_ratio"]))
96
+ sgkf = StratifiedGroupKFold(n_splits=n_splits, shuffle=True, random_state=seed)
97
+ train_idx, test_idx = next(sgkf.split(df, df[label_col], groups))
98
+ train = df.iloc[train_idx].reset_index(drop=True)
99
+ test = df.iloc[test_idx].reset_index(drop=True)
100
+
101
+ train_groups = set(groups.iloc[train_idx])
102
+ test_groups = set(groups.iloc[test_idx])
103
+ overlap = train_groups & test_groups
104
+ if overlap:
105
+ raise RuntimeError(
106
+ f"Grouped split produced {len(overlap)} groups on both sides of "
107
+ "train/test. StratifiedGroupKFold invariant violated, refusing "
108
+ "to silently continue."
109
+ )
110
+ logger.info(
111
+ "Grouped split: %d groups total, %d train / %d test, zero overlap confirmed.",
112
+ len(train_groups | test_groups), len(train_groups), len(test_groups),
113
+ )
114
+ return train, test
115
+
116
+
117
+ def dedup(
118
+ train_df: pd.DataFrame, test_df: pd.DataFrame, cfg: dict
119
+ ) -> tuple[pd.DataFrame, pd.DataFrame, dict]:
120
+ """Drop exact feature-space duplicates within and across splits.
121
+
122
+ Compares on every column except schema.drop_columns/label_column/
123
+ attack_category_column. A test row whose FEATURES exactly duplicate
124
+ a train row measures memorization, not generalization, regardless of
125
+ whether its label happens to match. Zero tolerance here is deliberately
126
+ stricter than general ML data-quality guidance, which treats under ~10%
127
+ train/test overlap as acceptable "Variety" (Zhou et al. 2024,
128
+ arXiv:2406.19614, Table II) - any leakage at all overstates a NIDS
129
+ classifier's real-world generalization, so this check flags on any
130
+ amount rather than only past a tolerance threshold.
131
+ """
132
+ schema = cfg["schema"]
133
+ ignore = set(schema["drop_columns"]) | {schema["label_column"]}
134
+ if schema["attack_category_column"]:
135
+ ignore.add(schema["attack_category_column"])
136
+ compare_cols = [c for c in train_df.columns if c not in ignore]
137
+
138
+ n_train0, n_test0 = len(train_df), len(test_df)
139
+
140
+ n_train_dupe = int(train_df.duplicated(subset=compare_cols).sum())
141
+ train_df = train_df.drop_duplicates(subset=compare_cols).reset_index(drop=True)
142
+
143
+ merged = test_df.merge(
144
+ train_df[compare_cols].drop_duplicates(), on=compare_cols, how="left", indicator=True
145
+ )
146
+ leak_mask = (merged["_merge"] == "both").values
147
+ n_leak = int(leak_mask.sum())
148
+ test_df = test_df.loc[~leak_mask].reset_index(drop=True)
149
+
150
+ n_test_dupe = int(test_df.duplicated(subset=compare_cols).sum())
151
+ test_df = test_df.drop_duplicates(subset=compare_cols).reset_index(drop=True)
152
+
153
+ stats = {
154
+ "train_rows_raw": n_train0,
155
+ "train_rows_deduped": len(train_df),
156
+ "train_duplicates_dropped": n_train_dupe,
157
+ "test_rows_raw": n_test0,
158
+ "test_rows_deduped": len(test_df),
159
+ "test_leakage_dropped": n_leak,
160
+ "test_duplicates_dropped": n_test_dupe,
161
+ }
162
+ return train_df, test_df, stats
163
+
164
+
165
+ def validate_loaded(train_df: pd.DataFrame, test_df: pd.DataFrame, cfg: dict) -> None:
166
+ """Hard-fail checks on structurally broken input, raised before anything
167
+ downstream (caching, audit, benchmarking) runs against it.
168
+
169
+ Distinct from audit checks: these aren't "worth a human's attention",
170
+ they're "nothing else in this pipeline will produce a meaningful
171
+ result," so they raise instead of returning a flagged finding.
172
+ """
173
+ errors: list[str] = []
174
+ schema = cfg["schema"]
175
+
176
+ for name, df in [("train", train_df), ("test", test_df)]:
177
+ if len(df) == 0:
178
+ errors.append(f"{name} data has zero rows after loading/splitting")
179
+ dupes = df.columns[df.columns.duplicated()].tolist()
180
+ if dupes:
181
+ errors.append(f"{name} data has duplicate column names: {sorted(set(dupes))}")
182
+
183
+ label_col = schema["label_column"]
184
+ if label_col not in train_df.columns:
185
+ errors.append(f"schema.label_column '{label_col}' not found in the loaded train data")
186
+ if label_col not in test_df.columns:
187
+ errors.append(f"schema.label_column '{label_col}' not found in the loaded test data")
188
+
189
+ attack_col = schema["attack_category_column"]
190
+ if attack_col:
191
+ if attack_col not in train_df.columns:
192
+ errors.append(f"schema.attack_category_column '{attack_col}' not found in the loaded train data")
193
+ if attack_col not in test_df.columns:
194
+ errors.append(f"schema.attack_category_column '{attack_col}' not found in the loaded test data")
195
+
196
+ if not errors: # only meaningful to check once the columns above exist
197
+ common = set(train_df.columns) & set(test_df.columns)
198
+ ignore = set(schema["drop_columns"]) | {label_col}
199
+ if attack_col:
200
+ ignore.add(attack_col)
201
+ if not (common - ignore):
202
+ errors.append(
203
+ "train and test data share no feature columns after excluding "
204
+ "label/attack_category/drop_columns, nothing left to train or evaluate on"
205
+ )
206
+
207
+ if errors:
208
+ raise ValueError("Loaded dataset failed validation:\n" + "\n".join(f" - {e}" for e in errors))