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,73 @@
1
+ """Scaling and class-balancing, driven by preprocessing.* config.
2
+
3
+ Sampling is fit on the training split only and never applied to test:
4
+ test sets stay at their real-world class distribution so metrics
5
+ reflect real deployment, matching the IDS project's balancer.py design.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+ from imblearn.combine import SMOTEENN
15
+ from imblearn.over_sampling import SMOTE
16
+ from imblearn.under_sampling import EditedNearestNeighbours, RandomUnderSampler
17
+ from sklearn.preprocessing import MinMaxScaler, RobustScaler, StandardScaler
18
+
19
+ from ..data import features
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ SCALERS = {"standard": StandardScaler, "minmax": MinMaxScaler, "robust": RobustScaler}
24
+ SAMPLERS = {
25
+ "smote": lambda seed: SMOTE(random_state=seed),
26
+ "smoteenn": lambda seed: SMOTEENN(random_state=seed),
27
+ "enn": lambda seed: EditedNearestNeighbours(), # deterministic, no randomness to seed
28
+ "random_undersample": lambda seed: RandomUnderSampler(random_state=seed),
29
+ }
30
+
31
+
32
+ def scale_features(
33
+ train_df: pd.DataFrame, test_df: pd.DataFrame, cfg: dict
34
+ ) -> tuple[np.ndarray, np.ndarray, list[str]]:
35
+ cols = features.feature_columns(train_df, cfg)
36
+ x_train_raw, x_test_raw = features.encode_aligned(train_df, test_df, cols)
37
+
38
+ method = cfg["preprocessing"]["scaling"]
39
+ if isinstance(method, list):
40
+ raise ValueError(
41
+ f"preprocessing.scaling is a list ({method}) - scale_features takes a "
42
+ "single strategy; use run_benchmark for multi-strategy comparison"
43
+ )
44
+ if method == "none":
45
+ return x_train_raw.to_numpy(), x_test_raw.to_numpy(), cols
46
+
47
+ scaler = SCALERS[method]()
48
+ x_train = scaler.fit_transform(x_train_raw)
49
+ x_test = scaler.transform(x_test_raw)
50
+ return x_train, x_test, cols
51
+
52
+
53
+ def apply_sampling(x_train: np.ndarray, y_train, cfg: dict, stage: str) -> tuple[np.ndarray, np.ndarray]:
54
+ algo = cfg["preprocessing"]["sampling"][stage]
55
+ if isinstance(algo, list):
56
+ raise ValueError(
57
+ f"preprocessing.sampling.{stage} is a list ({algo}) - apply_sampling takes a "
58
+ "single strategy; use run_benchmark for multi-strategy comparison"
59
+ )
60
+ y_train = np.asarray(y_train)
61
+ if algo == "none":
62
+ return x_train, y_train
63
+
64
+ sampler = SAMPLERS[algo](cfg["random_seed"])
65
+ try:
66
+ return sampler.fit_resample(x_train, y_train)
67
+ except ValueError as e:
68
+ logger.warning(
69
+ "Sampling '%s' failed for stage '%s' (%s), likely a class too small "
70
+ "for this sampler's neighbor count. Falling back to unsampled data.",
71
+ algo, stage, e,
72
+ )
73
+ return x_train, y_train
@@ -0,0 +1,2 @@
1
+ """Producing run outputs: scorecard, scorecard_plot, run_manager, drift.
2
+ """
@@ -0,0 +1,57 @@
1
+ """Prints a citation block for a completed run's scorecard.
2
+
3
+ Everything needed already lives in scorecard.json (dataset name, tool
4
+ version and commit, schema version, verdict, generated timestamp, and
5
+ the dataset_fingerprint content hashes): this reads that one file and
6
+ reformats it, it computes nothing new.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ from pathlib import Path
14
+
15
+ from . import scorecard as _scorecard
16
+
17
+
18
+ def _bibtex_key(dataset_name: str, train_content_hash: str) -> str:
19
+ slug = re.sub(r"[^a-z0-9]+", "_", dataset_name.lower()).strip("_")
20
+ return f"ids2eval_{slug}_{train_content_hash[:8]}"
21
+
22
+
23
+ def load_scorecard(run_dir: str | Path) -> dict:
24
+ path = Path(run_dir)
25
+ if path.is_dir():
26
+ path = path / "scorecard.json"
27
+ if not path.exists():
28
+ raise FileNotFoundError(f"No scorecard.json found at or under {run_dir}")
29
+ return _scorecard.upgrade(json.loads(path.read_text()))
30
+
31
+
32
+ def citation_bibtex(scorecard: dict) -> str:
33
+ fp = scorecard["dataset_fingerprint"]
34
+ year = scorecard["generated_at"][:4]
35
+ key = _bibtex_key(scorecard["dataset_name"], fp["train_content_hash"])
36
+ commit = scorecard["ids2eval_git_commit"]
37
+ verdict = scorecard["overall_status"].replace("_", " ")
38
+
39
+ # commit is only known for a git checkout (version_info.py shells out to
40
+ # git); a pip install from a downloaded zip - no .git directory at all -
41
+ # has no commit to report, and "commit unknown" reads worse in a
42
+ # citation than just not mentioning a commit at all.
43
+ howpublished = f"IDS2Eval v{scorecard['ids2eval_version']} (scorecard schema {scorecard['scorecard_schema_version']})"
44
+ if commit:
45
+ howpublished += f", commit {commit}"
46
+
47
+ fields = {
48
+ "title": f"IDS2Eval scorecard for {scorecard['dataset_name']}",
49
+ "howpublished": howpublished,
50
+ "note": (
51
+ f"{verdict}; train content hash {fp['train_content_hash']}; "
52
+ f"test content hash {fp['test_content_hash']}; generated {scorecard['generated_at']}"
53
+ ),
54
+ "year": year,
55
+ }
56
+ body = ",\n".join(f" {name} = {{{value}}}" for name, value in fields.items())
57
+ return f"@misc{{{key},\n{body}\n}}"
@@ -0,0 +1,103 @@
1
+ """Two-dataset equivalence comparison.
2
+
3
+ Answers a question no single-dataset audit can: are these two configs,
4
+ both claiming to load "the same" dataset (an official release and a
5
+ third-party mirror, say), actually equivalent? Loads both, computes
6
+ each one's fingerprint (run_manager.compute_dataset_fingerprint), and
7
+ compares schema, row counts, label distribution, and content.
8
+
9
+ Not a run_audit check: this compares two DATASETS, not one dataset's
10
+ train against its own test, and needs two configs rather than one, a
11
+ different enough shape to be its own CLI subcommand
12
+ (`ids2eval compare-datasets a.yaml b.yaml`) rather than an audit
13
+ finding. Each dataset's own train/test split can use a different
14
+ split_ratio or split_mode, so a train-vs-train, test-vs-test content
15
+ hash comparison alone could report a mismatch that's really just a
16
+ different split of identical underlying data; a combined (train+test
17
+ concatenated) hash catches that case, alongside the split-specific
18
+ hashes for detail.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import pandas as pd
24
+
25
+ from ..config import load_config
26
+ from ..data import dataset
27
+ from . import run_manager
28
+
29
+
30
+ def _combined_label_counts(fingerprint: dict) -> dict:
31
+ counts: dict[str, int] = {}
32
+ for source in ("train_class_distribution", "test_class_distribution"):
33
+ for label, count in fingerprint[source].items():
34
+ counts[label] = counts.get(label, 0) + count
35
+ return counts
36
+
37
+
38
+ def compare(config_path_a: str, config_path_b: str) -> dict:
39
+ cfg_a = load_config(config_path_a)
40
+ cfg_b = load_config(config_path_b)
41
+ train_a, test_a = dataset.load_split(cfg_a)
42
+ train_b, test_b = dataset.load_split(cfg_b)
43
+
44
+ fp_a = run_manager.compute_dataset_fingerprint(train_a, test_a, cfg_a)
45
+ fp_b = run_manager.compute_dataset_fingerprint(train_b, test_b, cfg_b)
46
+
47
+ combined_hash_a = run_manager.content_hash_unordered(pd.concat([train_a, test_a], ignore_index=True))
48
+ combined_hash_b = run_manager.content_hash_unordered(pd.concat([train_b, test_b], ignore_index=True))
49
+
50
+ labels_a = _combined_label_counts(fp_a)
51
+ labels_b = _combined_label_counts(fp_b)
52
+
53
+ return {
54
+ "dataset_a": cfg_a["dataset"]["name"],
55
+ "dataset_b": cfg_b["dataset"]["name"],
56
+ "schema_match": set(fp_a["columns"]) == set(fp_b["columns"]),
57
+ "columns_only_in_a": sorted(set(fp_a["columns"]) - set(fp_b["columns"])),
58
+ "columns_only_in_b": sorted(set(fp_b["columns"]) - set(fp_a["columns"])),
59
+ "row_count_a": fp_a["train_rows"] + fp_a["test_rows"],
60
+ "row_count_b": fp_b["train_rows"] + fp_b["test_rows"],
61
+ "row_count_match": (fp_a["train_rows"] + fp_a["test_rows"]) == (fp_b["train_rows"] + fp_b["test_rows"]),
62
+ "label_distribution_a": labels_a,
63
+ "label_distribution_b": labels_b,
64
+ "label_distribution_match": labels_a == labels_b,
65
+ "combined_content_match": combined_hash_a == combined_hash_b,
66
+ }
67
+
68
+
69
+ def render_report(result: dict) -> str:
70
+ def verdict(matched: bool, hard: bool = False) -> str:
71
+ return "PASS" if matched else ("FAIL" if hard else "WARN")
72
+
73
+ lines = [
74
+ f"Dataset A: {result['dataset_a']}",
75
+ f"Dataset B: {result['dataset_b']}",
76
+ "",
77
+ f"Schema match {verdict(result['schema_match'], hard=True)}",
78
+ f"Row count match {verdict(result['row_count_match'])}",
79
+ f"Label distribution {verdict(result['label_distribution_match'])}",
80
+ f"Combined content {verdict(result['combined_content_match'], hard=True)}",
81
+ ]
82
+
83
+ if not result["schema_match"]:
84
+ if result["columns_only_in_a"]:
85
+ lines.append(f" columns only in A: {result['columns_only_in_a']}")
86
+ if result["columns_only_in_b"]:
87
+ lines.append(f" columns only in B: {result['columns_only_in_b']}")
88
+ if not result["row_count_match"]:
89
+ lines.append(f" A: {result['row_count_a']:,} rows, B: {result['row_count_b']:,} rows")
90
+ if not result["label_distribution_match"]:
91
+ lines.append(f" A: {result['label_distribution_a']}")
92
+ lines.append(f" B: {result['label_distribution_b']}")
93
+
94
+ # combined_content_match, when true, already means the two datasets' full row
95
+ # sets are identical, which implies matching row counts and label distribution
96
+ # too - those two fields stay useful for diagnosing WHERE a mismatch is when
97
+ # combined_content_match is false, not as a separate verdict branch here.
98
+ lines.append("")
99
+ if result["schema_match"] and result["combined_content_match"]:
100
+ lines.append("Equivalent: same schema, row counts, label distribution, and content.")
101
+ else:
102
+ lines.append("Likely non-equivalent dataset versions.")
103
+ return "\n".join(lines)
@@ -0,0 +1,80 @@
1
+ """Compares this run's scorecard against the previous run in the same
2
+ output.dir, so a dataset-quality change between two runs of the same
3
+ config over time is visible without a person diffing two JSON files by
4
+ hand.
5
+
6
+ Every check here treats each run as fully independent: there is no
7
+ mechanism to notice that a source file was quietly swapped, or that a
8
+ dataset's duplication rate crept up between one export and the next.
9
+ Deequ and whylogs (see general ML data-quality tooling) both track
10
+ quality metrics across runs against a metrics repository rather than
11
+ only within one; this is a minimal version of that idea, comparing
12
+ against the immediately preceding run rather than a full history.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import logging
19
+ from pathlib import Path
20
+
21
+ from . import run_manager
22
+ from . import scorecard as _scorecard
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ def find_previous_run(output_dir: Path, current_run_dir: Path) -> Path | None:
28
+ """The most recent other run directory in output_dir that has a
29
+ scorecard.json, or None if there isn't one (a fresh output_dir, or
30
+ every prior run failed before the scorecard stage).
31
+ """
32
+ runs_dir = output_dir / run_manager.RUNS_DIRNAME
33
+ if not runs_dir.is_dir():
34
+ return None
35
+ candidates = sorted(
36
+ p for p in runs_dir.iterdir()
37
+ if p.is_dir() and p != current_run_dir and (p / "scorecard.json").is_file()
38
+ )
39
+ return candidates[-1] if candidates else None
40
+
41
+
42
+ def _status_by_check(scorecard: dict) -> dict[str, str]:
43
+ if scorecard.get("checks"):
44
+ return {r["check"]: (r["after"] or r["before"])["status"] for r in scorecard["checks"]}
45
+ return {f["check"]: f["status"] for f in scorecard.get("findings", [])}
46
+
47
+
48
+ def compare(scorecard: dict, previous_run_dir: Path) -> dict | None:
49
+ """None if the previous run's scorecard.json can't be read - a missing
50
+ comparison is reported as "no previous run" rather than crashing the
51
+ current run over an unrelated one's leftover state.
52
+ """
53
+ try:
54
+ previous = _scorecard.upgrade(json.loads((previous_run_dir / "scorecard.json").read_text()))
55
+ except (OSError, ValueError) as e:
56
+ logger.warning("Could not read previous run's scorecard at %s: %s", previous_run_dir, e)
57
+ return None
58
+
59
+ previous_status = _status_by_check(previous)
60
+ current_status = _status_by_check(scorecard)
61
+ changed_checks = [
62
+ {"check": check, "previous_status": previous_status[check], "current_status": status}
63
+ for check, status in current_status.items()
64
+ if check in previous_status and previous_status[check] != status
65
+ ]
66
+
67
+ previous_fp = previous.get("dataset_fingerprint", {})
68
+ current_fp = scorecard["dataset_fingerprint"]
69
+ fingerprint_changed = (
70
+ previous_fp.get("train_content_hash") != current_fp.get("train_content_hash")
71
+ or previous_fp.get("test_content_hash") != current_fp.get("test_content_hash")
72
+ )
73
+
74
+ return {
75
+ "previous_run": previous_run_dir.name,
76
+ "previous_generated_at": previous.get("generated_at"),
77
+ "previous_verdict": previous.get("overall_status"),
78
+ "fingerprint_changed": fingerprint_changed,
79
+ "changed_checks": changed_checks,
80
+ }
@@ -0,0 +1,83 @@
1
+ """Intervention manifest: reformats the checks that already ARE an
2
+ intervention (they refit under an altered condition and compare, rather
3
+ than measuring a static property of the data) into one structured record
4
+ per intervention, machine-readable, matching this project's own falsification
5
+ methodology directly rather than the ok/warning/flag scorecard's summary view.
6
+
7
+ This is a re-projection of findings IDS²Eval already computes, not a new
8
+ detection-and-selection engine: every check listed in INTERVENTION_CHECKS
9
+ always runs (or doesn't) according to audit.<check_name> in the config, the
10
+ same as every other check, rather than being dynamically triggered by a
11
+ prior finding's specific value. Building that latter, fuller "detect a
12
+ threat, then choose which intervention to run" rule engine is real,
13
+ larger, separate work this module does not attempt; what it does do is give
14
+ every intervention that DID run a citable, uniformly-shaped record.
15
+
16
+ Each record's four sections follow the shape:
17
+ finding: the check name, its status, and its own details dict as
18
+ evidence (heterogeneous by check, e.g. resplit_falsification's
19
+ random/grouped accuracy vs. feature_category_ablation_check's
20
+ per-category drop; kept as-is rather than forced into one
21
+ fixed evidence shape, which would lose information)
22
+ intervention: what was done (a fixed, human-readable label per check)
23
+ and that it was performed (always true here, since the
24
+ record only exists for a check that actually ran)
25
+ consequence: the same details dict again, from the intervention's own
26
+ perspective - the "before" and "after" of a resplit-style
27
+ check are the same two numbers as its "evidence"
28
+ conclusion: "material" / "investigated_not_material" / "inconclusive",
29
+ reusing the check's own explicit `material` field
30
+ (True/False/None) when it has one (repeated_seed_falsification_
31
+ check), otherwise derived from its ok/warning/flag status
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ # check name -> human-readable intervention label.
37
+ INTERVENTION_CHECKS = {
38
+ "resplit_falsification": "session/time-grouped resplit",
39
+ "repeated_seed_falsification_check": "session/time-grouped resplit, repeated across seeds",
40
+ "scenario_holdout_falsification": "held out one entire scenario from training",
41
+ "cross_capture_matrix_check": "trained on each scenario, tested on every other",
42
+ "feature_category_ablation_check": "removed one declared feature category and refit",
43
+ "cross_dataset_drift_check": "trained here, evaluated on an independent reference dataset",
44
+ "result_robustness_check": "refit under each buildable condition (split, dedup, identity columns)",
45
+ "artifact_sensitivity_check": "reshuffled one declared identity/timestamp column's values and re-predicted",
46
+ }
47
+
48
+ _STATUS_TO_CONCLUSION = {"flag": "material", "warning": "inconclusive", "ok": "investigated_not_material"}
49
+
50
+
51
+ def _conclusion_status(finding: dict) -> str:
52
+ material = finding.get("details", {}).get("material")
53
+ if material is True:
54
+ return "material"
55
+ if material is False:
56
+ return "investigated_not_material"
57
+ if material is None and "material" in finding.get("details", {}):
58
+ return "inconclusive" # explicit tri-state field said "can't tell", not just absent
59
+ return _STATUS_TO_CONCLUSION[finding["status"]]
60
+
61
+
62
+ def build_manifest(findings: list[dict]) -> list[dict]:
63
+ """One record per finding in `findings` whose check is in INTERVENTION_CHECKS
64
+ AND actually ran, not a no-op. Every check here returns `"details": {}` for its
65
+ no-op case (schema.scenario_column/reference_dataset/etc. not configured, or no
66
+ candidate columns present) - confirmed real against a live run where
67
+ scenario_holdout_falsification's no-op ("no schema.scenario_column configured")
68
+ would otherwise have shown up as intervention.performed: true, a real bug this
69
+ empty-details check exists specifically to prevent from recurring. A
70
+ not_applicable finding is skipped the same way, whatever its details hold.
71
+ """
72
+ records = []
73
+ for finding in findings:
74
+ check = finding["check"]
75
+ if check not in INTERVENTION_CHECKS or finding["status"] == "not_applicable" or not finding.get("details"):
76
+ continue
77
+ records.append({
78
+ "finding": {"check": check, "status": finding["status"], "evidence": finding.get("details", {})},
79
+ "intervention": {"type": check, "description": INTERVENTION_CHECKS[check], "performed": True},
80
+ "consequence": finding.get("details", {}),
81
+ "conclusion": {"status": _conclusion_status(finding)},
82
+ })
83
+ return records