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
ids2eval/cli.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""CLI entrypoint: config -> dataset split -> audit -> preprocessing -> benchmark.
|
|
2
|
+
|
|
3
|
+
python -m ids2eval --config my_config.yaml (same as: ids2eval run --config ...)
|
|
4
|
+
python -m ids2eval cite path/to/run_dir
|
|
5
|
+
python -m ids2eval validate-config --config my_config.yaml
|
|
6
|
+
python -m ids2eval compare-datasets a.yaml b.yaml
|
|
7
|
+
python -m ids2eval recommend path/to/run_dir
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import pandas as pd
|
|
19
|
+
import yaml
|
|
20
|
+
|
|
21
|
+
from ._json_utils import json_default
|
|
22
|
+
from .audit import STRUCTURAL_CHECKS, _checkpoint, run_audit
|
|
23
|
+
from .config import load_config
|
|
24
|
+
from .data import cache, dataset
|
|
25
|
+
from .data.label_grouping import apply_attack_type_mapping
|
|
26
|
+
from .modeling.benchmark import run_benchmark
|
|
27
|
+
from .reporting import cite, compare_datasets, drift, intervention_manifest, recommend, run_manager, scorecard
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
COMMANDS = ("run", "cite", "validate-config", "compare-datasets", "recommend")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_json_default = json_default
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _write_findings(path: Path, findings: list[dict]) -> None:
|
|
38
|
+
path.write_text(json.dumps(findings, indent=2, default=_json_default))
|
|
39
|
+
logger.info("Audit report written to %s", path)
|
|
40
|
+
for f in findings:
|
|
41
|
+
logger.info("[%s] %s: %s", f["status"].upper(), f["check"], f["summary"])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_args(argv=None) -> argparse.Namespace:
|
|
45
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
46
|
+
# No subcommand given (the original, still-supported invocation) - default to "run"
|
|
47
|
+
# so `ids2eval --config x.yaml` keeps working exactly as before.
|
|
48
|
+
if not argv or argv[0] not in COMMANDS:
|
|
49
|
+
argv = ["run", *argv]
|
|
50
|
+
|
|
51
|
+
parser = argparse.ArgumentParser(description="IDS2Eval: audit + benchmark an IDS dataset")
|
|
52
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
53
|
+
|
|
54
|
+
run_parser = subparsers.add_parser("run", help="Run the audit and/or benchmark on a config")
|
|
55
|
+
run_parser.add_argument("--config", required=True, help="Path to a YAML config file")
|
|
56
|
+
run_parser.add_argument("--skip-audit", action="store_true", help="Skip the data-quality audit checks")
|
|
57
|
+
run_parser.add_argument("--skip-benchmark", action="store_true", help="Skip classifier benchmarking")
|
|
58
|
+
|
|
59
|
+
cite_parser = subparsers.add_parser("cite", help="Print a citation for a completed run's scorecard")
|
|
60
|
+
cite_parser.add_argument("run_dir", help="A run directory, or its scorecard.json directly")
|
|
61
|
+
|
|
62
|
+
validate_parser = subparsers.add_parser(
|
|
63
|
+
"validate-config", help="Check a config file for errors without loading any data"
|
|
64
|
+
)
|
|
65
|
+
validate_parser.add_argument("--config", required=True, help="Path to a YAML config file")
|
|
66
|
+
|
|
67
|
+
compare_parser = subparsers.add_parser(
|
|
68
|
+
"compare-datasets", help="Check whether two configs load equivalent data"
|
|
69
|
+
)
|
|
70
|
+
compare_parser.add_argument("config_a", help="Path to the first config file")
|
|
71
|
+
compare_parser.add_argument("config_b", help="Path to the second config file")
|
|
72
|
+
|
|
73
|
+
recommend_parser = subparsers.add_parser(
|
|
74
|
+
"recommend", help="Suggest structural config fixes for a completed run's flagged/warned checks"
|
|
75
|
+
)
|
|
76
|
+
recommend_parser.add_argument("run_dir", help="A completed run directory")
|
|
77
|
+
recommend_parser.add_argument(
|
|
78
|
+
"--config", help="Apply patches onto this config file instead of the run's fully-resolved one"
|
|
79
|
+
)
|
|
80
|
+
recommend_parser.add_argument(
|
|
81
|
+
"--apply", action="store_true", help="Merge every recommendation that has a patch and print the result"
|
|
82
|
+
)
|
|
83
|
+
recommend_parser.add_argument("--output", help="Write the patched config here instead of stdout (needs --apply)")
|
|
84
|
+
|
|
85
|
+
return parser.parse_args(argv)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _run_recommend(args: argparse.Namespace) -> None:
|
|
89
|
+
recs, resolved_cfg = recommend.recommendations_for_run(args.run_dir)
|
|
90
|
+
print(recommend.render_report(recs))
|
|
91
|
+
if not args.apply:
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
base_cfg = yaml.safe_load(Path(args.config).read_text()) if args.config else resolved_cfg
|
|
95
|
+
patchable_ids = [r["id"] for r in recs if r["patch"]]
|
|
96
|
+
patched = recommend.apply_recommendations(base_cfg, recs, patchable_ids)
|
|
97
|
+
text = yaml.safe_dump(patched, sort_keys=False)
|
|
98
|
+
if args.output:
|
|
99
|
+
Path(args.output).write_text(text)
|
|
100
|
+
print(f"\nPatched config written to {args.output}")
|
|
101
|
+
else:
|
|
102
|
+
print("\n--- patched config ---")
|
|
103
|
+
print(text)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def main(argv=None) -> None:
|
|
107
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
|
108
|
+
args = parse_args(argv)
|
|
109
|
+
|
|
110
|
+
if args.command == "cite":
|
|
111
|
+
print(cite.citation_bibtex(cite.load_scorecard(args.run_dir)))
|
|
112
|
+
return
|
|
113
|
+
if args.command == "validate-config":
|
|
114
|
+
load_config(args.config) # raises ValueError with every problem found, if any
|
|
115
|
+
print(f"{args.config}: valid")
|
|
116
|
+
return
|
|
117
|
+
if args.command == "compare-datasets":
|
|
118
|
+
print(compare_datasets.render_report(compare_datasets.compare(args.config_a, args.config_b)))
|
|
119
|
+
return
|
|
120
|
+
if args.command == "recommend":
|
|
121
|
+
return _run_recommend(args)
|
|
122
|
+
|
|
123
|
+
cfg = load_config(args.config)
|
|
124
|
+
|
|
125
|
+
output_dir = Path(cfg["output"]["dir"])
|
|
126
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
|
|
128
|
+
logger.info("Loading dataset '%s'", cfg["dataset"]["name"])
|
|
129
|
+
cached = cache.try_load(cfg)
|
|
130
|
+
if cached is not None:
|
|
131
|
+
train_df, test_df = cached
|
|
132
|
+
else:
|
|
133
|
+
train_df, test_df = dataset.load_split(cfg)
|
|
134
|
+
# Applied before audit/benchmarking so both see the collapsed
|
|
135
|
+
# categories consistently, not just the final benchmark stage.
|
|
136
|
+
train_df, test_df = apply_attack_type_mapping(train_df, test_df, cfg)
|
|
137
|
+
dataset.validate_loaded(train_df, test_df, cfg)
|
|
138
|
+
cache.save(train_df, test_df, cfg)
|
|
139
|
+
logger.info("Split: train=%d rows, test=%d rows", len(train_df), len(test_df))
|
|
140
|
+
|
|
141
|
+
# A load-phase crash has no run_dir to report status into - there's no
|
|
142
|
+
# run to speak of yet, not even a failed one, until loading succeeds.
|
|
143
|
+
run_dir = run_manager.create_run_dir(output_dir)
|
|
144
|
+
run_manager.write_environment_info(run_dir)
|
|
145
|
+
run_manager.write_resolved_config(run_dir, cfg)
|
|
146
|
+
fingerprint = run_manager.write_dataset_fingerprint(run_dir, train_df, test_df, cfg)
|
|
147
|
+
logger.info("Run artifacts: %s", run_dir)
|
|
148
|
+
|
|
149
|
+
# Stable paths (unlike run_dir, which is fresh per timestamp) so a crash-and-rerun of
|
|
150
|
+
# the same output.dir finds the checkpoint a previous, incomplete run left behind.
|
|
151
|
+
# Separate files per pass: most checks aren't in STRUCTURAL_CHECKS and legitimately
|
|
152
|
+
# produce a DIFFERENT result before vs. after dedup (that's the reason both passes
|
|
153
|
+
# exist), so a single shared checkpoint keyed only by check name would wrongly let
|
|
154
|
+
# the after-pass reuse the before-pass's result for any of them.
|
|
155
|
+
checkpoint_dir = output_dir / ".checkpoint" if cfg["audit"]["checkpoint"] else None
|
|
156
|
+
checkpoint_path_before = str(checkpoint_dir / "before.json") if checkpoint_dir else None
|
|
157
|
+
checkpoint_path_after = str(checkpoint_dir / "after.json") if checkpoint_dir else None
|
|
158
|
+
|
|
159
|
+
stage = "audit_before"
|
|
160
|
+
try:
|
|
161
|
+
if not args.skip_audit:
|
|
162
|
+
# Must run before dedup. dedup_check reports duplication already
|
|
163
|
+
# present in the split, and dataset.dedup() would remove it first.
|
|
164
|
+
stage = "audit_before"
|
|
165
|
+
logger.info("Stage: audit (before dedup)")
|
|
166
|
+
findings_before = run_audit(train_df, test_df, cfg, checkpoint_path=checkpoint_path_before)
|
|
167
|
+
_write_findings(run_dir / "audit_report_before.json", findings_before)
|
|
168
|
+
|
|
169
|
+
if cfg["preprocessing"]["dedup"]:
|
|
170
|
+
stage = "dedup"
|
|
171
|
+
logger.info("Stage: dedup")
|
|
172
|
+
train_df, test_df, dedup_stats = dataset.dedup(train_df, test_df, cfg)
|
|
173
|
+
logger.info("Dedup: %s", dedup_stats)
|
|
174
|
+
|
|
175
|
+
if not args.skip_audit:
|
|
176
|
+
# Full suite again on the cleaned data, by explicit choice, so a
|
|
177
|
+
# claim like "12% duplicate leakage before, 0% after" is backed
|
|
178
|
+
# by two real runs rather than assumed from the dedup stats alone.
|
|
179
|
+
# STRUCTURAL_CHECKS are skipped here and copied from the before-pass
|
|
180
|
+
# instead: their result can't depend on dedup (known_issue_lookup
|
|
181
|
+
# looks at dataset.name, schema_fingerprint_check at column names,
|
|
182
|
+
# resplit_falsification reloads the raw data itself), so recomputing
|
|
183
|
+
# them - resplit_falsification's two RandomForest fits included -
|
|
184
|
+
# would be pure wasted work, not a second real measurement.
|
|
185
|
+
stage = "audit_after"
|
|
186
|
+
logger.info("Stage: audit (after dedup)")
|
|
187
|
+
recomputed = {
|
|
188
|
+
f["check"]: f for f in run_audit(
|
|
189
|
+
train_df, test_df, cfg, skip=STRUCTURAL_CHECKS, checkpoint_path=checkpoint_path_after,
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
reused = {f["check"]: f for f in findings_before if f["check"] in STRUCTURAL_CHECKS}
|
|
193
|
+
# dict order follows findings_before's schema order, not recompute order.
|
|
194
|
+
findings_after = [(recomputed | reused)[f["check"]] for f in findings_before]
|
|
195
|
+
_write_findings(run_dir / "audit_report_after.json", findings_after)
|
|
196
|
+
|
|
197
|
+
if not args.skip_audit:
|
|
198
|
+
# Reflects whichever findings describe the data actually shipped
|
|
199
|
+
# in this run - the post-dedup pass if dedup ran, otherwise the
|
|
200
|
+
# only pass there was.
|
|
201
|
+
stage = "scorecard"
|
|
202
|
+
final_findings = findings_after if cfg["preprocessing"]["dedup"] else findings_before
|
|
203
|
+
final_audit_stage = "after" if cfg["preprocessing"]["dedup"] else "before"
|
|
204
|
+
|
|
205
|
+
manifest = intervention_manifest.build_manifest(final_findings)
|
|
206
|
+
if manifest:
|
|
207
|
+
manifest_path = run_dir / "intervention_manifest.json"
|
|
208
|
+
manifest_path.write_text(json.dumps(manifest, indent=2, default=_json_default))
|
|
209
|
+
logger.info(
|
|
210
|
+
"Intervention manifest written to %s (%d intervention(s))",
|
|
211
|
+
manifest_path, len(manifest),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
sc = scorecard.build_scorecard(
|
|
215
|
+
final_findings, final_audit_stage, cfg, fingerprint,
|
|
216
|
+
findings_before=findings_before if final_audit_stage == "after" else None,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
previous_run_dir = drift.find_previous_run(output_dir, run_dir)
|
|
220
|
+
if previous_run_dir is not None:
|
|
221
|
+
comparison = drift.compare(sc, previous_run_dir)
|
|
222
|
+
if comparison is not None:
|
|
223
|
+
sc["previous_run_comparison"] = comparison
|
|
224
|
+
if comparison["changed_checks"]:
|
|
225
|
+
logger.info(
|
|
226
|
+
"Compared to %s: %d check(s) changed status",
|
|
227
|
+
comparison["previous_run"], len(comparison["changed_checks"]),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
has_plot = cfg["output"]["write_scorecard_plot"]
|
|
231
|
+
if has_plot:
|
|
232
|
+
from .reporting import scorecard_plot
|
|
233
|
+
scorecard_plot.render(sc, final_findings, run_dir / "scorecard.pdf", run_dir / "scorecard.png")
|
|
234
|
+
logger.info("Scorecard plot written to %s / .png", run_dir / "scorecard.pdf")
|
|
235
|
+
|
|
236
|
+
run_manager.write_scorecard(
|
|
237
|
+
run_dir, sc, scorecard.render_markdown(sc, has_plot=has_plot), scorecard.render_html(sc)
|
|
238
|
+
)
|
|
239
|
+
logger.info("Scorecard: %s -> %s", sc["overall_status"], run_dir / "scorecard.json")
|
|
240
|
+
|
|
241
|
+
if cfg["output"]["save_preprocessed"]:
|
|
242
|
+
stage = "save_preprocessed"
|
|
243
|
+
fmt = cfg["output"]["format"]
|
|
244
|
+
for name, df in [("train", train_df), ("test", test_df)]:
|
|
245
|
+
path = run_dir / f"{name}.{fmt}"
|
|
246
|
+
if fmt == "parquet":
|
|
247
|
+
df.to_parquet(path, index=False)
|
|
248
|
+
else:
|
|
249
|
+
df.to_csv(path, index=False)
|
|
250
|
+
logger.info("Wrote %s", path)
|
|
251
|
+
|
|
252
|
+
if not args.skip_benchmark:
|
|
253
|
+
stage = "benchmark"
|
|
254
|
+
logger.info("Stage: benchmark")
|
|
255
|
+
results_df, extras = run_benchmark(train_df, test_df, cfg)
|
|
256
|
+
results_path = run_dir / "benchmark_results.csv"
|
|
257
|
+
results_df.to_csv(results_path, index=False)
|
|
258
|
+
per_class_path = run_dir / "per_class_metrics.csv"
|
|
259
|
+
pd.DataFrame(extras["per_class_metrics"]).to_csv(per_class_path, index=False)
|
|
260
|
+
details_path = run_dir / "benchmark_details.json"
|
|
261
|
+
details_path.write_text(json.dumps(extras, indent=2, default=_json_default))
|
|
262
|
+
logger.info("Benchmark results written to %s", results_path)
|
|
263
|
+
logger.info("Per-class precision/recall/f1 written to %s", per_class_path)
|
|
264
|
+
logger.info("Per-classifier detail (confusion matrix, per-class report, "
|
|
265
|
+
"feature importance, best params) written to %s", details_path)
|
|
266
|
+
logger.info("\n%s", results_df.to_string(index=False))
|
|
267
|
+
|
|
268
|
+
run_manager.write_run_status(run_dir, status="completed")
|
|
269
|
+
if checkpoint_dir:
|
|
270
|
+
_checkpoint.clear(checkpoint_path_before)
|
|
271
|
+
_checkpoint.clear(checkpoint_path_after)
|
|
272
|
+
try:
|
|
273
|
+
checkpoint_dir.rmdir()
|
|
274
|
+
except OSError:
|
|
275
|
+
pass # not empty or already gone - fine, nothing left to clean up here
|
|
276
|
+
except Exception as e:
|
|
277
|
+
run_manager.write_run_status(run_dir, status="failed", failed_stage=stage, error=str(e))
|
|
278
|
+
raise
|
|
279
|
+
finally:
|
|
280
|
+
run_manager.cleanup_old_runs(output_dir, cfg["output"]["keep_runs"])
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
if __name__ == "__main__":
|
|
284
|
+
main()
|
ids2eval/config.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Load and validate IDS2Eval YAML configs against configs/schema.yaml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
VALID_CLASSIFIERS = {
|
|
12
|
+
"RandomForest", "XGBoost", "DecisionTree", "NaiveBayes",
|
|
13
|
+
"LogisticRegression", "KNN", "SVM", "LDA", "Stacking", "Voting",
|
|
14
|
+
"ExtraTrees", "HistGradientBoosting", "AdaBoost", "MLP",
|
|
15
|
+
}
|
|
16
|
+
NO_TUNE_CLASSIFIERS = {"NaiveBayes", "Stacking", "Voting"}
|
|
17
|
+
|
|
18
|
+
VALID_SAMPLING = {"none", "smote", "smoteenn", "enn", "random_undersample"}
|
|
19
|
+
VALID_SCALING = {"none", "standard", "minmax", "robust"}
|
|
20
|
+
VALID_SPLIT_MODE = {"random", "grouped"}
|
|
21
|
+
VALID_CALIBRATION = {"none", "platt", "isotonic"}
|
|
22
|
+
VALID_OUTPUT_FORMAT = {"parquet", "csv"}
|
|
23
|
+
|
|
24
|
+
# Which checks support audit.materiality_thresholds overrides, and the shape each one
|
|
25
|
+
# expects: a single number for a binary flag/ok check, or the two named tiers for a
|
|
26
|
+
# check with a three-tier ok/warning/flag scale. Kept here as the one place that has to
|
|
27
|
+
# stay in sync with which checks actually call ids2eval.audit._materiality.threshold().
|
|
28
|
+
MATERIALITY_SINGLE_CHECKS = {
|
|
29
|
+
"resplit_falsification", "cross_dataset_drift_check", "repeated_seed_falsification_check",
|
|
30
|
+
"homogeneity_test",
|
|
31
|
+
}
|
|
32
|
+
MATERIALITY_TIERED_CHECKS = {
|
|
33
|
+
"result_robustness_check", "cross_capture_matrix_check",
|
|
34
|
+
"scenario_holdout_falsification", "feature_category_ablation_check",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
DEFAULTS: dict[str, Any] = {
|
|
38
|
+
"random_seed": 0, # every random_state/seed in the pipeline (split, reservoir
|
|
39
|
+
# sampling, SMOTE/etc., classifier init, benchmark subsampling)
|
|
40
|
+
# derives from this one value, so a run is reproducible from
|
|
41
|
+
# resolved_config.json alone rather than 6 scattered constants
|
|
42
|
+
"dataset": {
|
|
43
|
+
"name": None,
|
|
44
|
+
"raw_files": [],
|
|
45
|
+
"train_file": None,
|
|
46
|
+
"test_file": None,
|
|
47
|
+
"column_names": [],
|
|
48
|
+
"split_ratio": 0.8,
|
|
49
|
+
"split_mode": "random",
|
|
50
|
+
"group_columns": [],
|
|
51
|
+
"chunk_size": None,
|
|
52
|
+
"max_rows": None,
|
|
53
|
+
},
|
|
54
|
+
"schema": {
|
|
55
|
+
"label_column": None,
|
|
56
|
+
"attack_category_column": None,
|
|
57
|
+
"drop_columns": [],
|
|
58
|
+
"id_like_columns": [],
|
|
59
|
+
"timestamp_column": None,
|
|
60
|
+
"flow_id_columns": [],
|
|
61
|
+
"scenario_column": None,
|
|
62
|
+
"feature_categories": {},
|
|
63
|
+
},
|
|
64
|
+
"label_grouping": {
|
|
65
|
+
"attack_type_mapping": {},
|
|
66
|
+
},
|
|
67
|
+
"preprocessing": {
|
|
68
|
+
"dedup": True,
|
|
69
|
+
"scaling": "standard",
|
|
70
|
+
"sampling": {"binary": "none", "type": "none"},
|
|
71
|
+
},
|
|
72
|
+
"audit": {
|
|
73
|
+
"dedup_check": True,
|
|
74
|
+
"label_conflict_check": True,
|
|
75
|
+
"near_duplicate_class_check": True,
|
|
76
|
+
"leakage_screen": True,
|
|
77
|
+
"one_rule_check": True,
|
|
78
|
+
"feature_auc_ranking_check": True,
|
|
79
|
+
"identity_column_flag": True,
|
|
80
|
+
"port_protocol_shortcut_check": True,
|
|
81
|
+
"temporal_leakage_check": True,
|
|
82
|
+
"temporal_realism_check": True,
|
|
83
|
+
"flow_group_leakage_check": True,
|
|
84
|
+
"row_order_leakage_check": True,
|
|
85
|
+
"homogeneity_test": True,
|
|
86
|
+
"resplit_falsification": True,
|
|
87
|
+
"scenario_holdout_falsification": True,
|
|
88
|
+
"class_distribution_report": True,
|
|
89
|
+
"low_cardinality_warning": True,
|
|
90
|
+
"schema_fingerprint_check": True,
|
|
91
|
+
"data_integrity_check": True,
|
|
92
|
+
"synthetic_realism_check": False,
|
|
93
|
+
"cross_dataset_drift_check": False,
|
|
94
|
+
"cross_capture_matrix_check": False,
|
|
95
|
+
"feature_category_ablation_check": False,
|
|
96
|
+
"artifact_sensitivity_check": False,
|
|
97
|
+
"result_robustness_check": False,
|
|
98
|
+
"known_issue_lookup": False,
|
|
99
|
+
"seed_sensitivity_check": False,
|
|
100
|
+
"repeated_seed_falsification_check": False,
|
|
101
|
+
# Number of independent seeds repeated_seed_falsification_check refits under;
|
|
102
|
+
# each additional seed is a full pair of RandomForest refits on the whole raw
|
|
103
|
+
# dataset, real, non-negligible compute on a large dataset, hence opt-in above.
|
|
104
|
+
"repeated_seed_count": 10,
|
|
105
|
+
"reference_dataset": None,
|
|
106
|
+
# Per-check materiality thresholds, frozen and user-visible rather than a buried
|
|
107
|
+
# module constant. Empty by default: each check keeps its own already-calibrated
|
|
108
|
+
# default (see the check's own module) unless overridden here by check name. A
|
|
109
|
+
# single number for a binary flag/ok check (e.g. resplit_falsification: 0.01), or
|
|
110
|
+
# {"warning": x, "flag": y} for a check with a three-tier ok/warning/flag scale
|
|
111
|
+
# (e.g. result_robustness_check). Deliberately per-check, not one global value:
|
|
112
|
+
# resplit_falsification's 0.01 and feature_category_ablation_check's 0.30 measure
|
|
113
|
+
# very different things (any session-leakage cost vs. expected ablation cost) and
|
|
114
|
+
# were each calibrated to their own check's scale.
|
|
115
|
+
"materiality_thresholds": {},
|
|
116
|
+
# Off by default: running checks concurrently means several may hold their own
|
|
117
|
+
# encoded feature matrix/RandomForest fit in memory at the same time instead of
|
|
118
|
+
# one at a time, raising peak memory versus the sequential default - a real
|
|
119
|
+
# concern on large-scale runs sized against sequential peak usage. Opt in once a
|
|
120
|
+
# run's VM has memory headroom to spare; max_parallel_checks caps how many run
|
|
121
|
+
# at once so this can't silently spike to running all ~28 checks simultaneously.
|
|
122
|
+
"parallel_checks": False,
|
|
123
|
+
"max_parallel_checks": 4,
|
|
124
|
+
# Off by default: persists each check's finding (and, for
|
|
125
|
+
# repeated_seed_falsification_check, per-seed progress) to
|
|
126
|
+
# output.dir/.checkpoint/ as it's computed, so a crash partway through a
|
|
127
|
+
# long full-scale run only redoes what hadn't finished yet on the next
|
|
128
|
+
# run with the same output.dir, rather than starting over. Cleared
|
|
129
|
+
# automatically once a run completes successfully. Mutually exclusive
|
|
130
|
+
# with parallel_checks: concurrent checks writing to one checkpoint
|
|
131
|
+
# file would race.
|
|
132
|
+
"checkpoint": False,
|
|
133
|
+
},
|
|
134
|
+
"classifiers": {
|
|
135
|
+
"list": "all",
|
|
136
|
+
"tuning": False,
|
|
137
|
+
"calibration": "none",
|
|
138
|
+
"hyperparameters": {},
|
|
139
|
+
"search_space": {},
|
|
140
|
+
},
|
|
141
|
+
"output": {
|
|
142
|
+
"dir": "./output",
|
|
143
|
+
"format": "parquet",
|
|
144
|
+
"save_preprocessed": True,
|
|
145
|
+
"keep_runs": 10,
|
|
146
|
+
"write_scorecard_plot": False,
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _deep_merge(base: dict, override: dict) -> dict:
|
|
152
|
+
merged = copy.deepcopy(base)
|
|
153
|
+
for key, value in override.items():
|
|
154
|
+
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
|
155
|
+
merged[key] = _deep_merge(merged[key], value)
|
|
156
|
+
else:
|
|
157
|
+
merged[key] = value
|
|
158
|
+
return merged
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def apply_patch(cfg: dict, patch: dict) -> dict:
|
|
162
|
+
"""Deep-merge a partial config (e.g. a reporting.recommend patch) onto an
|
|
163
|
+
already-resolved config. A list in `patch` replaces the corresponding
|
|
164
|
+
list in `cfg` wholesale, it is never concatenated.
|
|
165
|
+
"""
|
|
166
|
+
return _deep_merge(cfg, patch)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def load_config(path: str | Path) -> dict[str, Any]:
|
|
170
|
+
try:
|
|
171
|
+
with open(path) as f:
|
|
172
|
+
user_cfg = yaml.safe_load(f) or {}
|
|
173
|
+
except FileNotFoundError:
|
|
174
|
+
raise ValueError(
|
|
175
|
+
f"Config file not found: {path}. See configs/README.md in the repo for every "
|
|
176
|
+
"field, or the Quick start section of README.md for a minimal example."
|
|
177
|
+
) from None
|
|
178
|
+
cfg = _deep_merge(DEFAULTS, user_cfg)
|
|
179
|
+
validate_config(cfg)
|
|
180
|
+
return cfg
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def validate_config(cfg: dict[str, Any]) -> None:
|
|
184
|
+
errors: list[str] = []
|
|
185
|
+
|
|
186
|
+
if not isinstance(cfg["random_seed"], int) or isinstance(cfg["random_seed"], bool):
|
|
187
|
+
errors.append("random_seed must be an integer")
|
|
188
|
+
|
|
189
|
+
dataset = cfg["dataset"]
|
|
190
|
+
has_raw = bool(dataset["raw_files"])
|
|
191
|
+
has_presplit = bool(dataset["train_file"] and dataset["test_file"])
|
|
192
|
+
if not dataset["name"]:
|
|
193
|
+
errors.append("dataset.name is required")
|
|
194
|
+
if has_raw == has_presplit:
|
|
195
|
+
errors.append(
|
|
196
|
+
"dataset must specify exactly one of raw_files, or train_file+test_file together"
|
|
197
|
+
)
|
|
198
|
+
split_ratio = dataset["split_ratio"]
|
|
199
|
+
if not isinstance(split_ratio, (int, float)) or isinstance(split_ratio, bool) or not 0 < split_ratio < 1:
|
|
200
|
+
errors.append("dataset.split_ratio must be a number strictly between 0 and 1")
|
|
201
|
+
if dataset["split_mode"] not in VALID_SPLIT_MODE:
|
|
202
|
+
errors.append(f"dataset.split_mode must be one of {sorted(VALID_SPLIT_MODE)}")
|
|
203
|
+
if dataset["split_mode"] == "grouped" and not dataset["group_columns"]:
|
|
204
|
+
errors.append("dataset.group_columns is required when split_mode is 'grouped'")
|
|
205
|
+
column_names = dataset["column_names"]
|
|
206
|
+
if column_names:
|
|
207
|
+
if not isinstance(column_names, list) or not all(isinstance(c, str) for c in column_names):
|
|
208
|
+
errors.append("dataset.column_names must be a list of strings")
|
|
209
|
+
elif len(column_names) != len(set(column_names)):
|
|
210
|
+
dupes = sorted({c for c in column_names if column_names.count(c) > 1})
|
|
211
|
+
errors.append(f"dataset.column_names has duplicate name(s): {dupes}")
|
|
212
|
+
if dataset["chunk_size"] is not None and dataset["chunk_size"] <= 0:
|
|
213
|
+
errors.append("dataset.chunk_size must be a positive integer")
|
|
214
|
+
if dataset["max_rows"] is not None:
|
|
215
|
+
if dataset["max_rows"] <= 0:
|
|
216
|
+
errors.append("dataset.max_rows must be a positive integer")
|
|
217
|
+
if not dataset["chunk_size"]:
|
|
218
|
+
errors.append(
|
|
219
|
+
"dataset.max_rows requires dataset.chunk_size to be set. Reservoir "
|
|
220
|
+
"sampling still has to read the file in chunks to sample it"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
if not cfg["schema"]["label_column"]:
|
|
224
|
+
errors.append("schema.label_column is required")
|
|
225
|
+
if cfg["label_grouping"]["attack_type_mapping"] and not cfg["schema"]["attack_category_column"]:
|
|
226
|
+
errors.append(
|
|
227
|
+
"label_grouping.attack_type_mapping requires schema.attack_category_column "
|
|
228
|
+
"to be set, otherwise there's no column for it to apply to"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
scaling = cfg["preprocessing"]["scaling"]
|
|
232
|
+
scalings = scaling if isinstance(scaling, list) else [scaling]
|
|
233
|
+
invalid_scaling = [s for s in scalings if s not in VALID_SCALING]
|
|
234
|
+
if invalid_scaling:
|
|
235
|
+
errors.append(
|
|
236
|
+
f"preprocessing.scaling has invalid entries {invalid_scaling}; "
|
|
237
|
+
f"each must be one of {sorted(VALID_SCALING)}"
|
|
238
|
+
)
|
|
239
|
+
if not scalings:
|
|
240
|
+
errors.append("preprocessing.scaling must not be an empty list")
|
|
241
|
+
for stage, algo in cfg["preprocessing"]["sampling"].items():
|
|
242
|
+
algos = algo if isinstance(algo, list) else [algo]
|
|
243
|
+
invalid = [a for a in algos if a not in VALID_SAMPLING]
|
|
244
|
+
if invalid:
|
|
245
|
+
errors.append(
|
|
246
|
+
f"preprocessing.sampling.{stage} has invalid entries {invalid}; "
|
|
247
|
+
f"each must be one of {sorted(VALID_SAMPLING)}"
|
|
248
|
+
)
|
|
249
|
+
if not algos:
|
|
250
|
+
errors.append(f"preprocessing.sampling.{stage} must not be an empty list")
|
|
251
|
+
|
|
252
|
+
audit = cfg["audit"]
|
|
253
|
+
# known_issue_lookup is a pure curated-table lookup keyed on dataset.name,
|
|
254
|
+
# unlike the other two v2 checks, it needs no second dataset to compare against.
|
|
255
|
+
reference_needing_checks = ["synthetic_realism_check", "cross_dataset_drift_check"]
|
|
256
|
+
if any(audit[check] for check in reference_needing_checks) and not audit["reference_dataset"]:
|
|
257
|
+
errors.append(
|
|
258
|
+
f"audit.reference_dataset is required when any of {reference_needing_checks} is true"
|
|
259
|
+
)
|
|
260
|
+
# repeated_seed_falsification_check shares resplit_falsification's exact precondition:
|
|
261
|
+
# it's the same random-vs-grouped comparison, just repeated across seeds.
|
|
262
|
+
for check_name in ("resplit_falsification", "repeated_seed_falsification_check"):
|
|
263
|
+
if audit[check_name] and not (has_raw and dataset["group_columns"]):
|
|
264
|
+
if not has_raw:
|
|
265
|
+
errors.append(
|
|
266
|
+
f"audit.{check_name} needs dataset.raw_files, not a pre-split "
|
|
267
|
+
"train_file/test_file pair (it builds its own independent random-vs-"
|
|
268
|
+
f"grouped comparison split from the raw data). Set "
|
|
269
|
+
f"audit.{check_name}: false if this dataset has no "
|
|
270
|
+
"session-identifying columns to group by."
|
|
271
|
+
)
|
|
272
|
+
else:
|
|
273
|
+
errors.append(
|
|
274
|
+
f"audit.{check_name} needs dataset.group_columns set (e.g. "
|
|
275
|
+
"source/destination IP and port, or whatever identifies a session in "
|
|
276
|
+
f"this dataset) to build its grouped-split comparison. Set "
|
|
277
|
+
f"audit.{check_name}: false instead if this dataset has no "
|
|
278
|
+
"such columns, see guide/checks.md for what the check does."
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
n_seeds = audit["repeated_seed_count"]
|
|
282
|
+
if isinstance(n_seeds, bool) or not isinstance(n_seeds, int) or n_seeds < 1:
|
|
283
|
+
errors.append("audit.repeated_seed_count must be a positive integer")
|
|
284
|
+
|
|
285
|
+
if not isinstance(audit["parallel_checks"], bool):
|
|
286
|
+
errors.append("audit.parallel_checks must be true or false")
|
|
287
|
+
max_parallel = audit["max_parallel_checks"]
|
|
288
|
+
if isinstance(max_parallel, bool) or not isinstance(max_parallel, int) or max_parallel < 1:
|
|
289
|
+
errors.append("audit.max_parallel_checks must be a positive integer")
|
|
290
|
+
|
|
291
|
+
if not isinstance(audit["checkpoint"], bool):
|
|
292
|
+
errors.append("audit.checkpoint must be true or false")
|
|
293
|
+
if audit["checkpoint"] and audit["parallel_checks"]:
|
|
294
|
+
errors.append(
|
|
295
|
+
"audit.checkpoint and audit.parallel_checks cannot both be true: "
|
|
296
|
+
"concurrent checks writing to one checkpoint file would race"
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
thresholds = audit["materiality_thresholds"]
|
|
300
|
+
known_threshold_checks = MATERIALITY_SINGLE_CHECKS | MATERIALITY_TIERED_CHECKS
|
|
301
|
+
unknown_threshold_checks = set(thresholds) - known_threshold_checks
|
|
302
|
+
if unknown_threshold_checks:
|
|
303
|
+
errors.append(
|
|
304
|
+
f"audit.materiality_thresholds has entries for {sorted(unknown_threshold_checks)}, "
|
|
305
|
+
f"which don't support a threshold override; valid keys are {sorted(known_threshold_checks)}"
|
|
306
|
+
)
|
|
307
|
+
for check in MATERIALITY_SINGLE_CHECKS & set(thresholds):
|
|
308
|
+
if isinstance(thresholds[check], bool) or not isinstance(thresholds[check], (int, float)):
|
|
309
|
+
errors.append(
|
|
310
|
+
f"audit.materiality_thresholds.{check} must be a single number "
|
|
311
|
+
f"(this check has one flag/ok threshold, not separate warning/flag tiers)"
|
|
312
|
+
)
|
|
313
|
+
for check in MATERIALITY_TIERED_CHECKS & set(thresholds):
|
|
314
|
+
value = thresholds[check]
|
|
315
|
+
if not isinstance(value, dict) or set(value) != {"warning", "flag"}:
|
|
316
|
+
errors.append(
|
|
317
|
+
f'audit.materiality_thresholds.{check} must be a dict with exactly the keys '
|
|
318
|
+
f'"warning" and "flag" (this check has a three-tier ok/warning/flag scale)'
|
|
319
|
+
)
|
|
320
|
+
elif not all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in value.values()):
|
|
321
|
+
errors.append(f"audit.materiality_thresholds.{check}'s warning and flag values must be numbers")
|
|
322
|
+
elif value["warning"] >= value["flag"]:
|
|
323
|
+
errors.append(
|
|
324
|
+
f"audit.materiality_thresholds.{check}.warning ({value['warning']}) must be "
|
|
325
|
+
f"less than .flag ({value['flag']})"
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
classifiers = cfg["classifiers"]
|
|
329
|
+
clf_list = classifiers["list"]
|
|
330
|
+
if clf_list != "all":
|
|
331
|
+
unknown = set(clf_list) - VALID_CLASSIFIERS
|
|
332
|
+
if unknown:
|
|
333
|
+
errors.append(
|
|
334
|
+
f"classifiers.list has unknown entries {sorted(unknown)}; "
|
|
335
|
+
f"valid options are {sorted(VALID_CLASSIFIERS)}"
|
|
336
|
+
)
|
|
337
|
+
unknown_hp = set(classifiers["hyperparameters"]) - VALID_CLASSIFIERS
|
|
338
|
+
if unknown_hp:
|
|
339
|
+
errors.append(f"classifiers.hyperparameters has unknown classifier names {sorted(unknown_hp)}")
|
|
340
|
+
unknown_ss = set(classifiers["search_space"]) - VALID_CLASSIFIERS
|
|
341
|
+
if unknown_ss:
|
|
342
|
+
errors.append(f"classifiers.search_space has unknown classifier names {sorted(unknown_ss)}")
|
|
343
|
+
if classifiers["calibration"] not in VALID_CALIBRATION:
|
|
344
|
+
errors.append(f"classifiers.calibration must be one of {sorted(VALID_CALIBRATION)}")
|
|
345
|
+
|
|
346
|
+
if cfg["output"]["format"] not in VALID_OUTPUT_FORMAT:
|
|
347
|
+
errors.append(f"output.format must be one of {sorted(VALID_OUTPUT_FORMAT)}")
|
|
348
|
+
keep_runs = cfg["output"]["keep_runs"]
|
|
349
|
+
if keep_runs is not None and keep_runs <= 0:
|
|
350
|
+
errors.append("output.keep_runs must be a positive integer, or null to keep all runs")
|
|
351
|
+
|
|
352
|
+
if cfg["output"]["write_scorecard_plot"]:
|
|
353
|
+
try:
|
|
354
|
+
import matplotlib # noqa: F401
|
|
355
|
+
except ImportError:
|
|
356
|
+
errors.append(
|
|
357
|
+
"output.write_scorecard_plot is true but matplotlib isn't installed. "
|
|
358
|
+
'run: pip install "ids2eval[plots]"'
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
if errors:
|
|
362
|
+
raise ValueError("Invalid config:\n" + "\n".join(f" - {e}" for e in errors))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""IDS2Eval dashboard - a local web UI. Entry point: ids2eval.dashboard.server.main."""
|