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,87 @@
|
|
|
1
|
+
"""Near-duplicate feature vectors across different classes.
|
|
2
|
+
|
|
3
|
+
label_conflict_check catches identical features mapped to different
|
|
4
|
+
labels. This extends that to features close enough in scaled distance
|
|
5
|
+
to be effectively identical without matching exactly, the common case
|
|
6
|
+
when the same traffic-generation script produced two nominally
|
|
7
|
+
different attack labels, or two attack tools share almost all of their
|
|
8
|
+
flow statistics. Runs on train only: this is about label confusability
|
|
9
|
+
within the data a model actually learns from, not a train/test split
|
|
10
|
+
property (that's homogeneity_test's job).
|
|
11
|
+
|
|
12
|
+
Same nearest-neighbor machinery and near-zero distance threshold as
|
|
13
|
+
homogeneity_test, applied across classes rather than across the split.
|
|
14
|
+
A single NearestNeighbors index over all sampled rows, queried once,
|
|
15
|
+
avoids an O(classes^2) pairwise comparison.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
import pandas as pd
|
|
22
|
+
from sklearn.neighbors import NearestNeighbors
|
|
23
|
+
from sklearn.preprocessing import StandardScaler
|
|
24
|
+
|
|
25
|
+
from ..data import features
|
|
26
|
+
from ._status import NOT_APPLICABLE
|
|
27
|
+
|
|
28
|
+
SAMPLE_SIZE = 500
|
|
29
|
+
MIN_CLASS_SIZE = 20
|
|
30
|
+
NEAR_ZERO_DISTANCE = 1e-6
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def check(train_df: pd.DataFrame, cfg: dict) -> dict:
|
|
34
|
+
schema = cfg["schema"]
|
|
35
|
+
group_col = schema["attack_category_column"] or schema["label_column"]
|
|
36
|
+
cols = features.feature_columns(train_df, cfg)
|
|
37
|
+
|
|
38
|
+
class_counts = train_df[group_col].value_counts()
|
|
39
|
+
eligible = class_counts[class_counts >= MIN_CLASS_SIZE].index
|
|
40
|
+
if len(eligible) < 2:
|
|
41
|
+
return {
|
|
42
|
+
"check": "near_duplicate_class_check", "status": NOT_APPLICABLE,
|
|
43
|
+
"summary": "fewer than two classes have enough rows to compare", "details": {},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
rng = np.random.RandomState(0)
|
|
47
|
+
sample_parts = []
|
|
48
|
+
for cls in eligible:
|
|
49
|
+
idx = np.flatnonzero((train_df[group_col] == cls).to_numpy())
|
|
50
|
+
chosen = rng.choice(idx, size=min(SAMPLE_SIZE, len(idx)), replace=False)
|
|
51
|
+
sample_parts.append(train_df.iloc[chosen])
|
|
52
|
+
sample_df = pd.concat(sample_parts, ignore_index=True)
|
|
53
|
+
|
|
54
|
+
x_raw = features.encode_aligned(sample_df, sample_df, cols)[0]
|
|
55
|
+
x = StandardScaler().fit_transform(x_raw)
|
|
56
|
+
labels = sample_df[group_col].to_numpy()
|
|
57
|
+
|
|
58
|
+
nn = NearestNeighbors(n_neighbors=2, algorithm="auto", n_jobs=-1).fit(x)
|
|
59
|
+
dist, idx = nn.kneighbors(x)
|
|
60
|
+
nearest_dist, nearest_idx = dist[:, 1], idx[:, 1] # excluding self
|
|
61
|
+
|
|
62
|
+
cross_label = labels != labels[nearest_idx]
|
|
63
|
+
near_zero = nearest_dist < NEAR_ZERO_DISTANCE
|
|
64
|
+
confused = cross_label & near_zero
|
|
65
|
+
|
|
66
|
+
if not confused.any():
|
|
67
|
+
return {
|
|
68
|
+
"check": "near_duplicate_class_check", "status": "ok",
|
|
69
|
+
"summary": f"no near-zero-distance feature vectors found across {len(eligible)} classes tested",
|
|
70
|
+
"details": {"classes_tested": len(eligible)},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
pairs = pd.Series(
|
|
74
|
+
[tuple(sorted((a, b))) for a, b in zip(labels[confused], labels[nearest_idx[confused]], strict=True)]
|
|
75
|
+
).value_counts()
|
|
76
|
+
top_pairs = {f"{a} / {b}": int(n) for (a, b), n in pairs.head(5).items()}
|
|
77
|
+
affected_rows = int(confused.sum())
|
|
78
|
+
pct = affected_rows / len(sample_df)
|
|
79
|
+
|
|
80
|
+
summary = (
|
|
81
|
+
f"{affected_rows:,} of {len(sample_df):,} sampled rows ({pct:.2%}) have a near-zero-distance "
|
|
82
|
+
f"neighbor under a different label. Most affected pairs: {top_pairs}"
|
|
83
|
+
)
|
|
84
|
+
return {
|
|
85
|
+
"check": "near_duplicate_class_check", "status": "flag", "summary": summary,
|
|
86
|
+
"details": {"classes_tested": len(eligible), "affected_rows": affected_rows, "top_pairs": top_pairs},
|
|
87
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""One-rule ("one-liner") solvability check.
|
|
2
|
+
|
|
3
|
+
Per Wu & Keogh 2021 (IEEE TKDE): most widely-used time-series anomaly
|
|
4
|
+
benchmarks (Yahoo, NASA, Numenta) are largely solvable by a single line of
|
|
5
|
+
code - one feature compared to one threshold - which means published
|
|
6
|
+
comparisons of sophisticated algorithms on them are measuring nothing more
|
|
7
|
+
than who found the same threshold. leakage_screen already reports that a
|
|
8
|
+
feature carries most of a random forest's importance, but a share-of-
|
|
9
|
+
importance number is abstract; this check names the winning rule in plain
|
|
10
|
+
language ("Dst Port <= 1024.5"), which is far harder to argue with. A
|
|
11
|
+
depth-1 decision tree (a "stump") is exactly a brute-force search over
|
|
12
|
+
every feature and threshold for the single best split, done in one cheap
|
|
13
|
+
fit rather than a manual loop.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import pandas as pd
|
|
19
|
+
from sklearn.tree import DecisionTreeClassifier
|
|
20
|
+
|
|
21
|
+
from ..data import features
|
|
22
|
+
from ._status import NOT_APPLICABLE
|
|
23
|
+
|
|
24
|
+
# Wu & Keogh don't give a numeric cutoff (their test is "can a human write this in
|
|
25
|
+
# one line"); a one-rule accuracy this high is the tabular-data equivalent - a
|
|
26
|
+
# threshold on a single feature is about as simple a rule as exists.
|
|
27
|
+
ACCURACY_FLAG_THRESHOLD = 0.95
|
|
28
|
+
# A rule must also remove at least this share of the errors made by always
|
|
29
|
+
# predicting the training majority class: on a 99:1 dataset with no signal, any
|
|
30
|
+
# stump scores ~99% by predicting the majority, which says nothing about shortcuts.
|
|
31
|
+
MIN_ERROR_REDUCTION = 0.5
|
|
32
|
+
MAX_FIT_ROWS = 200_000
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def check(train_df: pd.DataFrame, test_df: pd.DataFrame, cfg: dict, seed: int = 0) -> dict:
|
|
36
|
+
"""seed varies the sample draw and the stump's own randomness. The
|
|
37
|
+
default single run is called with cfg["random_seed"] (run_audit's job);
|
|
38
|
+
seed_sensitivity_check separately sweeps its own fixed range of seeds to
|
|
39
|
+
test whether the flag/ok conclusion holds up regardless of that one value.
|
|
40
|
+
"""
|
|
41
|
+
label_col = cfg["schema"]["label_column"]
|
|
42
|
+
cols = features.feature_columns(train_df, cfg)
|
|
43
|
+
train_fit = train_df.sample(n=min(len(train_df), MAX_FIT_ROWS), random_state=seed)
|
|
44
|
+
x_train, x_test = features.encode_aligned(train_fit, test_df, cols, unseen="missing")
|
|
45
|
+
y_train, y_test = train_fit[label_col], test_df[label_col]
|
|
46
|
+
|
|
47
|
+
stump = DecisionTreeClassifier(max_depth=1, random_state=seed)
|
|
48
|
+
stump.fit(x_train, y_train)
|
|
49
|
+
tree = stump.tree_
|
|
50
|
+
root_feature = tree.feature[0]
|
|
51
|
+
|
|
52
|
+
if root_feature < 0: # -2: the root is already a leaf - e.g. a single-class split
|
|
53
|
+
return {
|
|
54
|
+
"check": "one_rule_check", "status": NOT_APPLICABLE,
|
|
55
|
+
"summary": "no single-feature rule found (fewer than two classes in the fitted sample)",
|
|
56
|
+
"details": {},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
rule = f"{x_train.columns[root_feature]} <= {tree.threshold[0]:.4g}"
|
|
60
|
+
train_acc = float(stump.score(x_train, y_train))
|
|
61
|
+
test_acc = float(stump.score(x_test, y_test))
|
|
62
|
+
|
|
63
|
+
majority = y_train.value_counts().idxmax()
|
|
64
|
+
baseline_acc = float((y_test == majority).mean())
|
|
65
|
+
error_reduction = (test_acc - baseline_acc) / (1 - baseline_acc) if baseline_acc < 1 else 0.0
|
|
66
|
+
|
|
67
|
+
flagged = test_acc > ACCURACY_FLAG_THRESHOLD and error_reduction >= MIN_ERROR_REDUCTION
|
|
68
|
+
status = "flag" if flagged else "ok"
|
|
69
|
+
summary = (
|
|
70
|
+
f"single rule '{rule}' reaches {test_acc:.1%} test accuracy ({train_acc:.1%} train), "
|
|
71
|
+
f"vs. {baseline_acc:.1%} from always predicting the majority class"
|
|
72
|
+
)
|
|
73
|
+
if flagged:
|
|
74
|
+
summary += ". This problem may be solvable without learning attack behavior"
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
"check": "one_rule_check", "status": status, "summary": summary,
|
|
78
|
+
"details": {
|
|
79
|
+
"rule": rule, "train_accuracy": train_acc, "test_accuracy": test_acc,
|
|
80
|
+
"majority_baseline_accuracy": baseline_acc, "error_reduction_vs_baseline": error_reduction,
|
|
81
|
+
},
|
|
82
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Combined port+protocol shortcut check.
|
|
2
|
+
|
|
3
|
+
identity_column_flag already tests each id_like_columns entry alone, but
|
|
4
|
+
destination port and protocol together are a specific, well-known NIDS
|
|
5
|
+
shortcut: a handful of (port, protocol) pairs (80/TCP, 443/TCP, 53/UDP,
|
|
6
|
+
...) route almost all benign traffic, and attack tools often target one
|
|
7
|
+
fixed port, so the pair can separate classes a single column understates,
|
|
8
|
+
generalizing across CIC, UNSW, and ToN-IoT-style datasets alike (the same
|
|
9
|
+
families where identity_column_flag already flags port alone). This
|
|
10
|
+
check quantifies that combined signal specifically, rather than leaving
|
|
11
|
+
it as an unmeasured gap between "one column alone" and "the full feature
|
|
12
|
+
set."
|
|
13
|
+
|
|
14
|
+
Needs no new config field: a port-like column is whichever entry in
|
|
15
|
+
schema.id_like_columns has "port" in its name (case-insensitive), and a
|
|
16
|
+
protocol-like column is whichever loaded column has "proto" in its name
|
|
17
|
+
(case-insensitive) - protocol is ordinarily a plain feature, not
|
|
18
|
+
id_like, so it is looked for across all columns rather than restricted
|
|
19
|
+
to id_like_columns. No-ops if either is missing.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import pandas as pd
|
|
25
|
+
from sklearn.ensemble import RandomForestClassifier
|
|
26
|
+
|
|
27
|
+
from ._auc import robust_auc
|
|
28
|
+
from ._status import NOT_APPLICABLE
|
|
29
|
+
|
|
30
|
+
AUC_FLAG_THRESHOLD = 0.8
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _find_column(candidates: list[str], needle: str) -> str | None:
|
|
34
|
+
for c in candidates:
|
|
35
|
+
if needle in c.lower():
|
|
36
|
+
return c
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def check(train_df: pd.DataFrame, test_df: pd.DataFrame, cfg: dict) -> dict:
|
|
41
|
+
id_cols = [c for c in cfg["schema"]["id_like_columns"] if c in train_df.columns]
|
|
42
|
+
port_col = _find_column(id_cols, "port")
|
|
43
|
+
proto_col = _find_column(list(train_df.columns), "proto")
|
|
44
|
+
|
|
45
|
+
if not port_col or not proto_col:
|
|
46
|
+
return {
|
|
47
|
+
"check": "port_protocol_shortcut_check", "status": NOT_APPLICABLE,
|
|
48
|
+
"summary": (
|
|
49
|
+
"no port-like entry in schema.id_like_columns and/or no proto-like "
|
|
50
|
+
"column found, this check needs both"
|
|
51
|
+
),
|
|
52
|
+
"details": {"port_column": port_col, "protocol_column": proto_col},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
label_col = cfg["schema"]["label_column"]
|
|
56
|
+
cols = [port_col, proto_col]
|
|
57
|
+
categories = {c: pd.Index(train_df[c].astype(str).unique()) for c in cols}
|
|
58
|
+
x_train = pd.DataFrame({c: categories[c].get_indexer(train_df[c].astype(str)) for c in cols})
|
|
59
|
+
x_test = pd.DataFrame({c: categories[c].get_indexer(test_df[c].astype(str)) for c in cols})
|
|
60
|
+
|
|
61
|
+
clf = RandomForestClassifier(n_estimators=50, random_state=cfg["random_seed"], n_jobs=-1)
|
|
62
|
+
clf.fit(x_train, train_df[label_col])
|
|
63
|
+
auc = robust_auc(test_df[label_col], clf.predict_proba(x_test), clf.classes_)
|
|
64
|
+
|
|
65
|
+
flagged = auc is not None and auc > AUC_FLAG_THRESHOLD
|
|
66
|
+
status = "flag" if flagged else "ok"
|
|
67
|
+
auc_text = "n/a" if auc is None else f"{auc:.3f}"
|
|
68
|
+
summary = f"standalone AUC of '{port_col}' + '{proto_col}' combined: {auc_text}"
|
|
69
|
+
if flagged:
|
|
70
|
+
summary += ". Port and protocol together may predict the label without attack behavior"
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
"check": "port_protocol_shortcut_check", "status": status, "summary": summary,
|
|
74
|
+
"details": {"port_column": port_col, "protocol_column": proto_col, "standalone_auc": auc},
|
|
75
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Repeated-seed resplit falsification (v2, opt-in).
|
|
2
|
+
|
|
3
|
+
resplit_falsification (Section 3.5) reports one random-split accuracy and
|
|
4
|
+
one grouped-split accuracy, each from a single seed. That single comparison
|
|
5
|
+
cannot itself distinguish "this gap is genuinely small" from "a different
|
|
6
|
+
seed for either split would show a materially different gap purely from
|
|
7
|
+
sampling variance" - exactly the limitation this project's own audit paper
|
|
8
|
+
disclosed and left as its most important piece of undone follow-up work.
|
|
9
|
+
|
|
10
|
+
This check closes that gap by repeating the same random-vs-grouped
|
|
11
|
+
comparison across several independent seeds (each seed reshuffles both the
|
|
12
|
+
random split and the StratifiedGroupKFold shuffle within the grouped split,
|
|
13
|
+
and refits a freshly-seeded RandomForest for each), then reports the mean
|
|
14
|
+
drop and a 95% confidence interval, using Student's t-distribution rather
|
|
15
|
+
than a normal approximation since n_seeds is deliberately small (5-10, not
|
|
16
|
+
hundreds - each additional seed is a full pair of RandomForest refits on
|
|
17
|
+
the whole raw dataset).
|
|
18
|
+
|
|
19
|
+
Materiality classification compares the CI against the SAME frozen
|
|
20
|
+
threshold resplit_falsification itself uses (audit.materiality_thresholds.
|
|
21
|
+
repeated_seed_falsification_check, defaulting to resplit_falsification's own
|
|
22
|
+
default), via a three-way rule rather than a single point-estimate cutoff:
|
|
23
|
+
|
|
24
|
+
material if the CI's lower bound is still above the threshold
|
|
25
|
+
(even the most conservative estimate says it matters)
|
|
26
|
+
not material if the CI's upper bound is below the threshold
|
|
27
|
+
(even the most generous estimate says it doesn't)
|
|
28
|
+
inconclusive if the CI straddles the threshold (n_seeds isn't enough
|
|
29
|
+
to tell, one way or the other - reported as its own
|
|
30
|
+
outcome, not silently folded into "ok", per this
|
|
31
|
+
project's own statistical-analysis-plan rule: never
|
|
32
|
+
infer "no effect" merely because a test isn't
|
|
33
|
+
significant)
|
|
34
|
+
|
|
35
|
+
Same preconditions as resplit_falsification: needs dataset.raw_files and
|
|
36
|
+
dataset.group_columns, and reloads the raw data itself, so its result
|
|
37
|
+
cannot depend on preprocessing.dedup either (see STRUCTURAL_CHECKS).
|
|
38
|
+
|
|
39
|
+
If audit.parallel_checks is set, the remaining (not yet checkpointed) seeds
|
|
40
|
+
run concurrently via a thread pool instead of one at a time - this check is
|
|
41
|
+
n_seeds independent full refits, so it's the single biggest reason a config
|
|
42
|
+
enabling it alongside only 1-2 other checks can still leave most of a
|
|
43
|
+
multi-vCPU VM idle if only the OUTER per-check dispatch were parallelized.
|
|
44
|
+
Never combined with checkpoint_path: per-seed checkpointing and concurrent
|
|
45
|
+
seeds are mutually exclusive at the config level (audit.checkpoint and
|
|
46
|
+
audit.parallel_checks can't both be true) for the same reason run_audit's
|
|
47
|
+
outer dispatch keeps them apart - concurrent writers to one checkpoint file
|
|
48
|
+
would race.
|
|
49
|
+
|
|
50
|
+
Accepts an already-loaded `combined` DataFrame so run_audit can share one
|
|
51
|
+
load across this, resplit_falsification, and result_robustness_check when
|
|
52
|
+
several run together, instead of each reloading the full raw dataset from
|
|
53
|
+
disk - a real cost at full scale (a real failure this was built to avoid:
|
|
54
|
+
a 70M-row config running all three of these checks was reloading the same
|
|
55
|
+
data three times sequentially, contributing to repeated VM-level
|
|
56
|
+
instability on a long run). Still loads it itself when called standalone,
|
|
57
|
+
so this is purely an optimization, not a behavior change.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
from __future__ import annotations
|
|
61
|
+
|
|
62
|
+
import concurrent.futures
|
|
63
|
+
from statistics import mean, stdev
|
|
64
|
+
|
|
65
|
+
import pandas as pd
|
|
66
|
+
from scipy import stats
|
|
67
|
+
|
|
68
|
+
from ..data import dataset
|
|
69
|
+
from . import _checkpoint, _materiality
|
|
70
|
+
from ._fit_score import fit_and_score
|
|
71
|
+
from ._status import NOT_APPLICABLE
|
|
72
|
+
from .resplit import MATERIAL_DROP_THRESHOLD
|
|
73
|
+
|
|
74
|
+
DEFAULT_N_SEEDS = 10
|
|
75
|
+
MIN_N_SEEDS = 3 # below this, a t-based CI is too unstable to report meaningfully
|
|
76
|
+
_CHECK_NAME = "repeated_seed_falsification_check"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def check(
|
|
80
|
+
cfg: dict, n_seeds: int | None = None, checkpoint_path: str | None = None,
|
|
81
|
+
combined: pd.DataFrame | None = None,
|
|
82
|
+
) -> dict:
|
|
83
|
+
dataset_cfg = cfg["dataset"]
|
|
84
|
+
if n_seeds is None:
|
|
85
|
+
n_seeds = cfg["audit"]["repeated_seed_count"]
|
|
86
|
+
if n_seeds < MIN_N_SEEDS:
|
|
87
|
+
return {
|
|
88
|
+
"check": _CHECK_NAME, "status": NOT_APPLICABLE,
|
|
89
|
+
"summary": f"audit.repeated_seed_count ({n_seeds}) is below the minimum of "
|
|
90
|
+
f"{MIN_N_SEEDS} needed for a meaningful confidence interval",
|
|
91
|
+
"details": {},
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
label_col = cfg["schema"]["label_column"]
|
|
95
|
+
|
|
96
|
+
# Resume from any seeds already checkpointed (e.g. a prior run of this same
|
|
97
|
+
# checkpoint_path that crashed partway through) rather than redoing them -
|
|
98
|
+
# this check alone can dominate a full-scale run's cost, so losing all
|
|
99
|
+
# progress to a mid-sweep crash is exactly the failure mode worth avoiding.
|
|
100
|
+
completed: dict[int, float] = (
|
|
101
|
+
_checkpoint.get_seed_progress(checkpoint_path, _CHECK_NAME) if checkpoint_path else {}
|
|
102
|
+
)
|
|
103
|
+
drops = [completed[s] for s in sorted(completed) if s < n_seeds]
|
|
104
|
+
start_seed = len(drops)
|
|
105
|
+
|
|
106
|
+
remaining_seeds = list(range(start_seed, n_seeds))
|
|
107
|
+
if combined is None and remaining_seeds:
|
|
108
|
+
combined = dataset.load_raw_combined(dataset_cfg, seed=cfg["random_seed"])
|
|
109
|
+
|
|
110
|
+
def run_seed(seed: int) -> float:
|
|
111
|
+
# Scored one split at a time, not both built up front - see resplit.check's
|
|
112
|
+
# own comment: holding combined plus both split pairs at once is the
|
|
113
|
+
# dominant memory cost at full scale, and this runs n_seeds times.
|
|
114
|
+
random_train, random_test = dataset._random_split(combined, label_col, dataset_cfg, seed=seed)
|
|
115
|
+
random_acc = fit_and_score(random_train, random_test, label_col, cfg, seed=seed)
|
|
116
|
+
del random_train, random_test
|
|
117
|
+
|
|
118
|
+
grouped_train, grouped_test = dataset._grouped_split(combined, label_col, dataset_cfg, seed=seed)
|
|
119
|
+
grouped_acc = fit_and_score(grouped_train, grouped_test, label_col, cfg, seed=seed)
|
|
120
|
+
del grouped_train, grouped_test
|
|
121
|
+
return random_acc - grouped_acc
|
|
122
|
+
|
|
123
|
+
if cfg["audit"]["parallel_checks"] and not checkpoint_path and len(remaining_seeds) > 1:
|
|
124
|
+
max_workers = min(cfg["audit"]["max_parallel_checks"], len(remaining_seeds))
|
|
125
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as pool:
|
|
126
|
+
# map preserves input order in its results regardless of completion order,
|
|
127
|
+
# so drops_by_seed stays seed-ordered the same as the sequential path.
|
|
128
|
+
drops.extend(pool.map(run_seed, remaining_seeds))
|
|
129
|
+
else:
|
|
130
|
+
for seed in remaining_seeds:
|
|
131
|
+
drop = run_seed(seed)
|
|
132
|
+
drops.append(drop)
|
|
133
|
+
if checkpoint_path:
|
|
134
|
+
_checkpoint.save_seed_progress(checkpoint_path, _CHECK_NAME, seed, drop)
|
|
135
|
+
|
|
136
|
+
mean_drop = mean(drops)
|
|
137
|
+
sample_std = stdev(drops) # n_seeds >= MIN_N_SEEDS >= 2, safe for stdev's ddof=1
|
|
138
|
+
# Two-sided 95% CI for the mean via the t-distribution (n_seeds - 1 degrees of
|
|
139
|
+
# freedom), appropriate for this few independent repeats rather than a normal
|
|
140
|
+
# approximation that would understate the uncertainty at this sample size.
|
|
141
|
+
margin = stats.t.ppf(0.975, df=n_seeds - 1) * sample_std / (n_seeds ** 0.5)
|
|
142
|
+
ci_low, ci_high = mean_drop - margin, mean_drop + margin
|
|
143
|
+
|
|
144
|
+
threshold = _materiality.threshold(
|
|
145
|
+
cfg, "repeated_seed_falsification_check", MATERIAL_DROP_THRESHOLD,
|
|
146
|
+
)
|
|
147
|
+
if ci_low > threshold:
|
|
148
|
+
material, status = True, "flag"
|
|
149
|
+
verdict = (
|
|
150
|
+
f"material: even the most conservative estimate in the 95% CI ({ci_low:+.4f}) "
|
|
151
|
+
f"exceeds the {threshold:+.4f} materiality threshold"
|
|
152
|
+
)
|
|
153
|
+
elif ci_high < threshold:
|
|
154
|
+
material, status = False, "ok"
|
|
155
|
+
verdict = (
|
|
156
|
+
f"not material: even the most generous estimate in the 95% CI ({ci_high:+.4f}) "
|
|
157
|
+
f"stays below the {threshold:+.4f} materiality threshold"
|
|
158
|
+
)
|
|
159
|
+
else:
|
|
160
|
+
material, status = None, "warning"
|
|
161
|
+
verdict = (
|
|
162
|
+
f"inconclusive: the 95% CI [{ci_low:+.4f}, {ci_high:+.4f}] straddles the "
|
|
163
|
+
f"{threshold:+.4f} materiality threshold; {n_seeds} seeds aren't enough to "
|
|
164
|
+
"tell whether this gap is real or sampling noise"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
"check": _CHECK_NAME,
|
|
169
|
+
"status": status,
|
|
170
|
+
"summary": (
|
|
171
|
+
f"grouped-split accuracy drop across {n_seeds} seeds: mean={mean_drop:+.4f}, "
|
|
172
|
+
f"95% CI=[{ci_low:+.4f}, {ci_high:+.4f}]; {verdict}"
|
|
173
|
+
),
|
|
174
|
+
"details": {
|
|
175
|
+
"n_seeds": n_seeds,
|
|
176
|
+
"drops_by_seed": drops,
|
|
177
|
+
"mean_drop": mean_drop,
|
|
178
|
+
"ci_95": [ci_low, ci_high],
|
|
179
|
+
"materiality_threshold": threshold,
|
|
180
|
+
"material": material,
|
|
181
|
+
},
|
|
182
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Resplit falsification test (paper Section 3.5).
|
|
2
|
+
|
|
3
|
+
The nearest-neighbor test alone can't distinguish "this class is
|
|
4
|
+
inherently homogeneous" from "a different split would reveal real
|
|
5
|
+
difficulty that random sampling happens to hide", both hypotheses look
|
|
6
|
+
identical under random sampling. Constructing a non-random split along a
|
|
7
|
+
dimension the random split ignores (session/group identity) and
|
|
8
|
+
observing whether accuracy changes breaks that symmetry: if
|
|
9
|
+
session-correlated leakage across the random split's boundary had been
|
|
10
|
+
doing real work, the grouped split should cost measurable accuracy.
|
|
11
|
+
|
|
12
|
+
Independent of whichever split_mode is configured for the main run:
|
|
13
|
+
always builds both splits from the raw data to compare them directly.
|
|
14
|
+
|
|
15
|
+
Accepts an already-loaded `combined` DataFrame so run_audit can share one
|
|
16
|
+
load across this, scenario_holdout_falsification, result_robustness_check,
|
|
17
|
+
and repeated_seed_falsification_check when several run together - each
|
|
18
|
+
independently reloading the full raw dataset from disk is a real cost at
|
|
19
|
+
full scale (a real failure this was built to avoid: a 70M-row config
|
|
20
|
+
running all three of these checks was reloading the same data three times
|
|
21
|
+
sequentially, contributing to repeated VM-level instability on a long run).
|
|
22
|
+
Still loads it itself when called standalone (e.g. every existing direct
|
|
23
|
+
unit test), so this is purely an optimization, not a behavior change.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import pandas as pd
|
|
29
|
+
|
|
30
|
+
from ..data import dataset
|
|
31
|
+
from . import _materiality
|
|
32
|
+
from ._fit_score import fit_and_score
|
|
33
|
+
|
|
34
|
+
# A grouped-split accuracy drop below this is read as "structurally
|
|
35
|
+
# ruling out session-correlated leakage as the explanation," not proof
|
|
36
|
+
# of zero leakage of any kind. Overridable via audit.materiality_thresholds.
|
|
37
|
+
# resplit_falsification.
|
|
38
|
+
MATERIAL_DROP_THRESHOLD = 0.01
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def check(cfg: dict, seed: int = 0, combined: pd.DataFrame | None = None) -> dict:
|
|
42
|
+
dataset_cfg = cfg["dataset"]
|
|
43
|
+
label_col = cfg["schema"]["label_column"]
|
|
44
|
+
if combined is None:
|
|
45
|
+
combined = dataset.load_raw_combined(dataset_cfg, seed=cfg["random_seed"])
|
|
46
|
+
|
|
47
|
+
# Scored one split at a time, not both built up front: at full scale (hundreds
|
|
48
|
+
# of millions of rows) holding combined plus both split pairs simultaneously -
|
|
49
|
+
# five full-size DataFrames at once - is itself the dominant memory cost, a
|
|
50
|
+
# real failure this was built to avoid, not a theoretical one. fit_and_score
|
|
51
|
+
# only needs the float back, so each split's train/test can be freed before
|
|
52
|
+
# the next is built.
|
|
53
|
+
random_train, random_test = dataset._random_split(combined, label_col, dataset_cfg, seed=seed)
|
|
54
|
+
random_acc = fit_and_score(random_train, random_test, label_col, cfg, seed=seed)
|
|
55
|
+
n_random = len(random_test)
|
|
56
|
+
del random_train, random_test
|
|
57
|
+
|
|
58
|
+
grouped_train, grouped_test = dataset._grouped_split(combined, label_col, dataset_cfg, seed=seed)
|
|
59
|
+
grouped_acc = fit_and_score(grouped_train, grouped_test, label_col, cfg, seed=seed)
|
|
60
|
+
n_grouped = len(grouped_test)
|
|
61
|
+
del grouped_train, grouped_test
|
|
62
|
+
drop = random_acc - grouped_acc
|
|
63
|
+
|
|
64
|
+
threshold = _materiality.threshold(cfg, "resplit_falsification", MATERIAL_DROP_THRESHOLD)
|
|
65
|
+
# One comparison on finite test sets: a drop only counts as material once it
|
|
66
|
+
# clears the threshold by more than its own sampling margin. A drop above the
|
|
67
|
+
# threshold but within that margin is reported as inconclusive (warning).
|
|
68
|
+
margin = _materiality.accuracy_gap_margin(random_acc, n_random, grouped_acc, n_grouped)
|
|
69
|
+
if drop - margin > threshold:
|
|
70
|
+
status, verdict = "flag", (
|
|
71
|
+
"grouped split costs measurable accuracy beyond test-set sampling noise, "
|
|
72
|
+
"session-correlated leakage in the random split may be doing real work"
|
|
73
|
+
)
|
|
74
|
+
elif drop > threshold:
|
|
75
|
+
status, verdict = "warning", (
|
|
76
|
+
f"the drop exceeds the {threshold:.2f} threshold but lies within its sampling margin "
|
|
77
|
+
f"(+/-{margin:.4f}) at this test size; inconclusive, see repeated_seed_falsification_check"
|
|
78
|
+
)
|
|
79
|
+
else:
|
|
80
|
+
status, verdict = "ok", (
|
|
81
|
+
"grouped split reproduces random-split accuracy: no evidence that information tied "
|
|
82
|
+
"to the grouping key drives the result (other grouping keys and shortcuts are not tested)"
|
|
83
|
+
)
|
|
84
|
+
summary = (
|
|
85
|
+
f"random-split accuracy={random_acc:.4f}, grouped-split accuracy={grouped_acc:.4f} "
|
|
86
|
+
f"(drop={drop:+.4f}); {verdict}"
|
|
87
|
+
)
|
|
88
|
+
return {
|
|
89
|
+
"check": "resplit_falsification", "status": status, "summary": summary,
|
|
90
|
+
"details": {"random_accuracy": random_acc, "grouped_accuracy": grouped_acc, "drop": drop,
|
|
91
|
+
"sampling_margin": margin, "random_test_rows": n_random, "grouped_test_rows": n_grouped},
|
|
92
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Result robustness matrix (v2, opt-in).
|
|
2
|
+
|
|
3
|
+
Every other check here measures a property of the data. This measures
|
|
4
|
+
something different: how much a benchmark's headline accuracy moves
|
|
5
|
+
across a handful of methodology choices that are each individually
|
|
6
|
+
reasonable, random split vs. a session-grouped split, with vs. without
|
|
7
|
+
exact duplicates, with vs. without declared identity columns, rather
|
|
8
|
+
than assuming a single reported number is the whole story. A result
|
|
9
|
+
that barely moves across these conditions is robust to how it was
|
|
10
|
+
produced; one that swings widely was measuring the methodology as much
|
|
11
|
+
as the attack behavior.
|
|
12
|
+
|
|
13
|
+
Deliberately reports the full accuracy-by-condition vector rather than
|
|
14
|
+
collapsing it into one fragility score: which specific condition moved
|
|
15
|
+
the result, and by how much, is the useful information, and a single
|
|
16
|
+
number would hide exactly that (the same reasoning class_distribution_
|
|
17
|
+
report and every per-class breakdown in this project already follow).
|
|
18
|
+
|
|
19
|
+
Builds on infrastructure already in place rather than duplicating it:
|
|
20
|
+
dataset._random_split/_grouped_split (resplit_falsification), dataset.
|
|
21
|
+
dedup (dedup_check), and fit_and_score's column-subset override
|
|
22
|
+
(feature_category_ablation_check). Needs dataset.raw_files, like every
|
|
23
|
+
other check that builds its own comparison splits from the raw data;
|
|
24
|
+
the grouped-split and identity-column conditions are each included only
|
|
25
|
+
when the config actually supports them (dataset.group_columns,
|
|
26
|
+
schema.id_like_columns), so a minimal config still gets at least the
|
|
27
|
+
random-split-vs-dedup comparison.
|
|
28
|
+
|
|
29
|
+
Every condition's (train, test) pair is built first - relatively cheap next
|
|
30
|
+
to the fit itself - so the actual fit_and_score calls can be dispatched
|
|
31
|
+
together, concurrently via a thread pool when audit.parallel_checks is set.
|
|
32
|
+
|
|
33
|
+
Accepts an already-loaded `combined` DataFrame so run_audit can share one
|
|
34
|
+
load across this, resplit_falsification, and repeated_seed_falsification_
|
|
35
|
+
check when several run together, instead of each reloading the full raw
|
|
36
|
+
dataset from disk - a real cost at full scale. Still loads it itself when
|
|
37
|
+
called standalone, so this is purely an optimization.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import concurrent.futures
|
|
43
|
+
|
|
44
|
+
import pandas as pd
|
|
45
|
+
|
|
46
|
+
from ..data import dataset, features
|
|
47
|
+
from . import _materiality
|
|
48
|
+
from ._fit_score import fit_and_score
|
|
49
|
+
from ._status import NOT_APPLICABLE
|
|
50
|
+
|
|
51
|
+
# Overridable via audit.materiality_thresholds.result_robustness_check ({"warning": .., "flag": ..}).
|
|
52
|
+
SPREAD_FLAG_THRESHOLD = 0.15
|
|
53
|
+
SPREAD_WARNING_THRESHOLD = 0.08
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def check(cfg: dict, combined: pd.DataFrame | None = None) -> dict:
|
|
57
|
+
dataset_cfg = cfg["dataset"]
|
|
58
|
+
if not dataset_cfg["raw_files"]:
|
|
59
|
+
return {
|
|
60
|
+
"check": "result_robustness_check", "status": NOT_APPLICABLE,
|
|
61
|
+
"summary": "requires dataset.raw_files, not applicable to a pre-split train_file/test_file pair",
|
|
62
|
+
"details": {},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
seed = cfg["random_seed"]
|
|
66
|
+
label_col = cfg["schema"]["label_column"]
|
|
67
|
+
if combined is None:
|
|
68
|
+
combined = dataset.load_raw_combined(dataset_cfg, seed=seed)
|
|
69
|
+
|
|
70
|
+
# name -> (train_df, test_df, cols_override); built up front so the actual fits
|
|
71
|
+
# (the expensive part) can all be dispatched together below.
|
|
72
|
+
pending: dict[str, tuple] = {}
|
|
73
|
+
random_train, random_test = dataset._random_split(combined, label_col, dataset_cfg, seed=seed)
|
|
74
|
+
pending["random_split"] = (random_train, random_test, None)
|
|
75
|
+
|
|
76
|
+
if dataset_cfg["group_columns"]:
|
|
77
|
+
try:
|
|
78
|
+
grouped_train, grouped_test = dataset._grouped_split(combined, label_col, dataset_cfg, seed=seed)
|
|
79
|
+
pending["grouped_split"] = (grouped_train, grouped_test, None)
|
|
80
|
+
except (ValueError, RuntimeError):
|
|
81
|
+
pass # same graceful-skip as resplit_falsification when a grouped split can't be built
|
|
82
|
+
|
|
83
|
+
deduped_train, deduped_test, dedup_stats = dataset.dedup(random_train.copy(), random_test.copy(), cfg)
|
|
84
|
+
# Only a distinct condition if dedup actually removed something; otherwise it's
|
|
85
|
+
# an identical refit of the random_split condition, not a second data point.
|
|
86
|
+
if (
|
|
87
|
+
dedup_stats["train_rows_raw"] != dedup_stats["train_rows_deduped"]
|
|
88
|
+
or dedup_stats["test_rows_raw"] != dedup_stats["test_rows_deduped"]
|
|
89
|
+
):
|
|
90
|
+
pending["deduplicated"] = (deduped_train, deduped_test, None)
|
|
91
|
+
|
|
92
|
+
id_cols = [c for c in cfg["schema"]["id_like_columns"] if c in random_train.columns]
|
|
93
|
+
if id_cols:
|
|
94
|
+
all_cols = features.feature_columns(random_train, cfg)
|
|
95
|
+
without_ids = [c for c in all_cols if c not in id_cols]
|
|
96
|
+
if without_ids:
|
|
97
|
+
pending["identity_columns_dropped"] = (random_train, random_test, without_ids)
|
|
98
|
+
|
|
99
|
+
if len(pending) < 2:
|
|
100
|
+
return {
|
|
101
|
+
"check": "result_robustness_check", "status": NOT_APPLICABLE,
|
|
102
|
+
"summary": "fewer than two conditions could be built to compare", "details": {},
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
test_rows = {name: len(test_df) for name, (_, test_df, _) in pending.items()}
|
|
106
|
+
|
|
107
|
+
def run(name: str) -> tuple[str, float]:
|
|
108
|
+
train_df, test_df, cols = pending[name]
|
|
109
|
+
return name, fit_and_score(train_df, test_df, label_col, cfg, cols=cols, seed=seed)
|
|
110
|
+
|
|
111
|
+
if cfg["audit"]["parallel_checks"]:
|
|
112
|
+
max_workers = min(cfg["audit"]["max_parallel_checks"], len(pending))
|
|
113
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as pool:
|
|
114
|
+
conditions = dict(pool.map(run, pending))
|
|
115
|
+
else:
|
|
116
|
+
conditions = dict(run(name) for name in pending)
|
|
117
|
+
|
|
118
|
+
worst_condition, worst_acc = min(conditions.items(), key=lambda kv: kv[1])
|
|
119
|
+
best_condition, best_acc = max(conditions.items(), key=lambda kv: kv[1])
|
|
120
|
+
spread = best_acc - worst_acc
|
|
121
|
+
|
|
122
|
+
tiers = _materiality.threshold(
|
|
123
|
+
cfg, "result_robustness_check",
|
|
124
|
+
{"warning": SPREAD_WARNING_THRESHOLD, "flag": SPREAD_FLAG_THRESHOLD},
|
|
125
|
+
)
|
|
126
|
+
# Tiers are judged on the spread minus its sampling margin at these test sizes,
|
|
127
|
+
# so test-set noise alone can't flag; a spread that crosses a tier only within
|
|
128
|
+
# that margin is reported as a warning (inconclusive) instead.
|
|
129
|
+
margin = _materiality.accuracy_gap_margin(
|
|
130
|
+
best_acc, test_rows[best_condition], worst_acc, test_rows[worst_condition],
|
|
131
|
+
)
|
|
132
|
+
if spread - margin > tiers["flag"]:
|
|
133
|
+
status = "flag"
|
|
134
|
+
elif spread > tiers["warning"]:
|
|
135
|
+
status = "warning"
|
|
136
|
+
else:
|
|
137
|
+
status = "ok"
|
|
138
|
+
|
|
139
|
+
summary = (
|
|
140
|
+
f"accuracy ranges from {worst_acc:.4f} ({worst_condition}) to {best_acc:.4f} "
|
|
141
|
+
f"({best_condition}) across {len(conditions)} conditions, a spread of {spread:.4f} "
|
|
142
|
+
f"(sampling margin +/-{margin:.4f})"
|
|
143
|
+
)
|
|
144
|
+
if status != "ok":
|
|
145
|
+
summary += "; the headline result depends on methodology choices as much as the classifier"
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
"check": "result_robustness_check", "status": status, "summary": summary,
|
|
149
|
+
"details": {
|
|
150
|
+
"accuracy_by_condition": conditions, "spread": spread,
|
|
151
|
+
"worst_condition": worst_condition, "best_condition": best_condition,
|
|
152
|
+
"sampling_margin": margin,
|
|
153
|
+
},
|
|
154
|
+
}
|