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.
- ids2eval/__init__.py +0 -0
- ids2eval/__main__.py +4 -0
- ids2eval/_json_utils.py +24 -0
- ids2eval/audit/__init__.py +239 -0
- ids2eval/audit/_auc.py +48 -0
- ids2eval/audit/_by_class.py +29 -0
- ids2eval/audit/_checkpoint.py +74 -0
- ids2eval/audit/_fit_score.py +40 -0
- ids2eval/audit/_materiality.py +28 -0
- ids2eval/audit/_status.py +15 -0
- ids2eval/audit/_time.py +14 -0
- ids2eval/audit/artifact_sensitivity.py +88 -0
- ids2eval/audit/class_distribution.py +57 -0
- ids2eval/audit/cross_capture_matrix.py +115 -0
- ids2eval/audit/cross_dataset_drift.py +74 -0
- ids2eval/audit/data_integrity.py +67 -0
- ids2eval/audit/dedup.py +67 -0
- ids2eval/audit/feature_auc_ranking.py +125 -0
- ids2eval/audit/feature_category_ablation.py +98 -0
- ids2eval/audit/flow_group_leakage.py +66 -0
- ids2eval/audit/homogeneity.py +160 -0
- ids2eval/audit/identity_columns.py +171 -0
- ids2eval/audit/known_issues.py +144 -0
- ids2eval/audit/label_conflict.py +92 -0
- ids2eval/audit/leakage.py +99 -0
- ids2eval/audit/near_duplicate_class.py +87 -0
- ids2eval/audit/one_rule.py +82 -0
- ids2eval/audit/port_protocol_shortcut.py +75 -0
- ids2eval/audit/repeated_seed_falsification.py +182 -0
- ids2eval/audit/resplit.py +92 -0
- ids2eval/audit/result_robustness.py +154 -0
- ids2eval/audit/row_order_leakage.py +86 -0
- ids2eval/audit/scenario_holdout.py +115 -0
- ids2eval/audit/schema_fingerprint.py +52 -0
- ids2eval/audit/seed_sensitivity.py +69 -0
- ids2eval/audit/synthetic_realism.py +73 -0
- ids2eval/audit/temporal_leakage.py +58 -0
- ids2eval/audit/temporal_realism.py +81 -0
- ids2eval/cli.py +284 -0
- ids2eval/config.py +362 -0
- ids2eval/dashboard/__init__.py +1 -0
- ids2eval/dashboard/__main__.py +3 -0
- ids2eval/dashboard/server.py +527 -0
- ids2eval/dashboard/static/app.js +1064 -0
- ids2eval/dashboard/static/charts.js +366 -0
- ids2eval/dashboard/static/index.html +33 -0
- ids2eval/dashboard/static/style.css +293 -0
- ids2eval/data/__init__.py +3 -0
- ids2eval/data/cache.py +99 -0
- ids2eval/data/chunked_io.py +387 -0
- ids2eval/data/dataset.py +208 -0
- ids2eval/data/features.py +95 -0
- ids2eval/data/label_grouping.py +38 -0
- ids2eval/modeling/__init__.py +3 -0
- ids2eval/modeling/benchmark.py +360 -0
- ids2eval/modeling/classifiers.py +145 -0
- ids2eval/modeling/preprocessing.py +73 -0
- ids2eval/reporting/__init__.py +2 -0
- ids2eval/reporting/cite.py +57 -0
- ids2eval/reporting/compare_datasets.py +103 -0
- ids2eval/reporting/drift.py +80 -0
- ids2eval/reporting/intervention_manifest.py +83 -0
- ids2eval/reporting/recommend.py +402 -0
- ids2eval/reporting/run_manager.py +184 -0
- ids2eval/reporting/scorecard.py +875 -0
- ids2eval/reporting/scorecard_plot.py +162 -0
- ids2eval/version_info.py +36 -0
- ids2eval-0.2.2.dist-info/METADATA +119 -0
- ids2eval-0.2.2.dist-info/RECORD +73 -0
- ids2eval-0.2.2.dist-info/WHEEL +5 -0
- ids2eval-0.2.2.dist-info/entry_points.txt +4 -0
- ids2eval-0.2.2.dist-info/licenses/LICENSE +661 -0
- 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)
|
ids2eval/data/dataset.py
ADDED
|
@@ -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))
|