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,160 @@
|
|
|
1
|
+
"""Nearest-neighbor class-homogeneity test (paper Section 3.4).
|
|
2
|
+
|
|
3
|
+
For each class, compares test-to-train nearest-neighbor distance against
|
|
4
|
+
a *control*, train-internal nearest-neighbor distance, excluding self, via
|
|
5
|
+
the near-zero-distance rate each side shows (the share of rows whose
|
|
6
|
+
nearest neighbor is an effectively-exact feature-space duplicate). If test
|
|
7
|
+
rows are no more prone to this than train rows are to each other,
|
|
8
|
+
near-duplication is a property of the class population itself (present
|
|
9
|
+
within each split independently), not train/test boundary leakage. If
|
|
10
|
+
test rows are notably more prone to it, that's a real leakage signature
|
|
11
|
+
the resplit-falsification check can then corroborate.
|
|
12
|
+
|
|
13
|
+
Reports a 95% CI on the rate difference (test minus control) against a
|
|
14
|
+
pre-specified margin rather than a single p-value, since failing to detect
|
|
15
|
+
a difference is not the same claim as having shown there is none. Leakage
|
|
16
|
+
is one-sided, so a class is "leakage" when the CI's lower bound exceeds
|
|
17
|
+
+margin, cleared when its upper bound is below +margin ("equivalent" if
|
|
18
|
+
the CI also sits inside +/-margin, "below_control" if test rows are
|
|
19
|
+
materially less near-duplicated than train is with itself), and
|
|
20
|
+
"inconclusive" when the CI straddles +margin.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import math
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
import pandas as pd
|
|
29
|
+
from sklearn.neighbors import NearestNeighbors
|
|
30
|
+
from sklearn.preprocessing import StandardScaler
|
|
31
|
+
|
|
32
|
+
from ..data import features
|
|
33
|
+
from . import _materiality
|
|
34
|
+
from ._status import NOT_APPLICABLE
|
|
35
|
+
|
|
36
|
+
SAMPLE_SIZE = 500
|
|
37
|
+
MIN_CLASS_SIZE = 20
|
|
38
|
+
MAX_INDEX_ROWS = 200_000
|
|
39
|
+
# Post-StandardScaler distance below which two rows are treated as an
|
|
40
|
+
# effectively-exact feature-space duplicate.
|
|
41
|
+
NEAR_ZERO_DISTANCE = 1e-6
|
|
42
|
+
# Equivalence margin on the near-zero-distance *rate difference* (test minus
|
|
43
|
+
# control), on the same 0-1 scale as the rates themselves: if test rows are
|
|
44
|
+
# near-zero-distance to train no more than 5 percentage points more often
|
|
45
|
+
# than train rows are to each other, that gap is read as noise around zero,
|
|
46
|
+
# not a leakage signature - the same order of magnitude as this codebase's
|
|
47
|
+
# other diagnostic materiality thresholds (e.g. resplit_falsification's 0.01
|
|
48
|
+
# accuracy-drop, result_robustness_check's 0.08/0.15 spread tiers), chosen
|
|
49
|
+
# for consistency rather than independently derived. Overridable via
|
|
50
|
+
# audit.materiality_thresholds.homogeneity_test.
|
|
51
|
+
EQUIVALENCE_MARGIN = 0.05
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_Z = 1.959964 # z_{0.975}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _wilson(p: float, n: int) -> tuple[float, float]:
|
|
58
|
+
centre = (p + _Z**2 / (2 * n)) / (1 + _Z**2 / n)
|
|
59
|
+
half = _Z * math.sqrt(p * (1 - p) / n + _Z**2 / (4 * n**2)) / (1 + _Z**2 / n)
|
|
60
|
+
return centre - half, centre + half
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _rate_diff_ci(p_test: float, n_test: int, p_control: float, n_control: int) -> tuple[float, float]:
|
|
64
|
+
"""95% Newcombe hybrid-score CI for the difference of two independent
|
|
65
|
+
proportions (test rate minus control rate). Unlike the Wald interval it
|
|
66
|
+
stays a proper interval when a rate is at 0 or 1, which match rates
|
|
67
|
+
often are here."""
|
|
68
|
+
diff = p_test - p_control
|
|
69
|
+
lo_t, hi_t = _wilson(p_test, n_test)
|
|
70
|
+
lo_c, hi_c = _wilson(p_control, n_control)
|
|
71
|
+
lower = diff - math.sqrt((p_test - lo_t) ** 2 + (hi_c - p_control) ** 2)
|
|
72
|
+
upper = diff + math.sqrt((hi_t - p_test) ** 2 + (p_control - lo_c) ** 2)
|
|
73
|
+
return lower, upper
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def check(train_df: pd.DataFrame, test_df: pd.DataFrame, cfg: dict) -> dict:
|
|
77
|
+
schema = cfg["schema"]
|
|
78
|
+
group_col = schema["attack_category_column"] or schema["label_column"]
|
|
79
|
+
cols = features.feature_columns(train_df, cfg)
|
|
80
|
+
|
|
81
|
+
index_df = train_df.sample(n=min(len(train_df), MAX_INDEX_ROWS), random_state=cfg["random_seed"])
|
|
82
|
+
x_index_raw, x_test_raw = features.encode_aligned(index_df, test_df, cols)
|
|
83
|
+
scaler = StandardScaler().fit(x_index_raw)
|
|
84
|
+
x_index = scaler.transform(x_index_raw)
|
|
85
|
+
x_test = scaler.transform(x_test_raw)
|
|
86
|
+
|
|
87
|
+
nn = NearestNeighbors(n_neighbors=2, algorithm="auto", n_jobs=-1).fit(x_index)
|
|
88
|
+
margin = _materiality.threshold(cfg, "homogeneity_test", EQUIVALENCE_MARGIN)
|
|
89
|
+
|
|
90
|
+
per_class = {}
|
|
91
|
+
flagged_classes = []
|
|
92
|
+
inconclusive_classes = []
|
|
93
|
+
for cls, count in train_df[group_col].value_counts().items():
|
|
94
|
+
test_mask = test_df[group_col] == cls
|
|
95
|
+
index_mask = (index_df[group_col] == cls).values
|
|
96
|
+
if count < MIN_CLASS_SIZE or test_mask.sum() < MIN_CLASS_SIZE or index_mask.sum() < MIN_CLASS_SIZE:
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
test_sample_idx = np.random.RandomState(0).choice(
|
|
100
|
+
np.flatnonzero(test_mask.values), size=min(SAMPLE_SIZE, test_mask.sum()), replace=False
|
|
101
|
+
)
|
|
102
|
+
control_sample_idx = np.random.RandomState(0).choice(
|
|
103
|
+
np.flatnonzero(index_mask), size=min(SAMPLE_SIZE, index_mask.sum()), replace=False
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
test_dist, _ = nn.kneighbors(x_test[test_sample_idx], n_neighbors=1)
|
|
107
|
+
control_dist, _ = nn.kneighbors(x_index[control_sample_idx], n_neighbors=2)
|
|
108
|
+
|
|
109
|
+
test_dist = test_dist[:, 0]
|
|
110
|
+
control_dist = control_dist[:, 1] # nearest neighbor excluding self
|
|
111
|
+
|
|
112
|
+
test_near_zero = float((test_dist < NEAR_ZERO_DISTANCE).mean())
|
|
113
|
+
control_near_zero = float((control_dist < NEAR_ZERO_DISTANCE).mean())
|
|
114
|
+
n_test, n_control = len(test_dist), len(control_dist)
|
|
115
|
+
ci_low, ci_high = _rate_diff_ci(test_near_zero, n_test, control_near_zero, n_control)
|
|
116
|
+
|
|
117
|
+
# Leakage is one-sided (test closer to train than the control), so the
|
|
118
|
+
# decision bound is +margin only. A CI entirely below -margin means the
|
|
119
|
+
# test rows are materially *less* near-duplicated than train is with
|
|
120
|
+
# itself: decisively not leakage, so it must not land in "inconclusive".
|
|
121
|
+
if ci_low > margin:
|
|
122
|
+
classification = "leakage"
|
|
123
|
+
flagged_classes.append(cls)
|
|
124
|
+
elif ci_high < margin:
|
|
125
|
+
classification = "equivalent" if ci_low > -margin else "below_control"
|
|
126
|
+
else:
|
|
127
|
+
classification = "inconclusive"
|
|
128
|
+
inconclusive_classes.append(cls)
|
|
129
|
+
|
|
130
|
+
per_class[cls] = {
|
|
131
|
+
"test_near_zero_rate": test_near_zero,
|
|
132
|
+
"control_near_zero_rate": control_near_zero,
|
|
133
|
+
"rate_diff_ci_95": [ci_low, ci_high],
|
|
134
|
+
"equivalence_margin": margin,
|
|
135
|
+
"classification": classification,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
status = "flag" if flagged_classes else ("warning" if inconclusive_classes else "ok")
|
|
139
|
+
if not per_class:
|
|
140
|
+
status = NOT_APPLICABLE
|
|
141
|
+
detail = f"no class has {MIN_CLASS_SIZE} rows in train, test and the control sample, nothing to compare"
|
|
142
|
+
elif flagged_classes:
|
|
143
|
+
detail = f"leakage signature (95% CI lower bound exceeds the {margin:+.2f} margin) in: {flagged_classes}"
|
|
144
|
+
elif inconclusive_classes:
|
|
145
|
+
detail = (
|
|
146
|
+
f"inconclusive (95% CI straddles the +/-{margin:.2f} equivalence margin) for: "
|
|
147
|
+
f"{inconclusive_classes}; sample size isn't enough to tell for these classes"
|
|
148
|
+
)
|
|
149
|
+
else:
|
|
150
|
+
detail = (
|
|
151
|
+
f"for every class the 95% CI upper bound on (test-to-train minus train-internal) "
|
|
152
|
+
f"near-duplicate rate is below the +{margin:.2f} margin, so no class shows a "
|
|
153
|
+
f"train/test leakage signature"
|
|
154
|
+
)
|
|
155
|
+
summary = f"{len(per_class)} classes tested; {detail}"
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
"check": "homogeneity_test", "status": status, "summary": summary,
|
|
159
|
+
"details": {"per_class": per_class, "equivalence_margin": margin},
|
|
160
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Identity-column checks: standalone predictive power + low-cardinality risk.
|
|
2
|
+
|
|
3
|
+
Per Sarhan et al. 2022 (NetFlow-standard feature set drops IP/port
|
|
4
|
+
columns) and Kostas et al. 2024/2025 (packet-level IP/port models hit
|
|
5
|
+
near-100% in-dataset, lose >90% cross-dataset): id_like_columns (IP,
|
|
6
|
+
port, MAC) are checked both for how well they predict the label alone
|
|
7
|
+
(identity_column_flag) and for how few unique values they take relative
|
|
8
|
+
to dataset size (low_cardinality_warning generalizes N-BaIoT's
|
|
9
|
+
per-device overfitting finding to CIC/UNSW's limited attacker/victim IP
|
|
10
|
+
pool).
|
|
11
|
+
|
|
12
|
+
identity_column_flag asks one specific question: on this split, how well
|
|
13
|
+
does looking an identifier value up in the training data predict the
|
|
14
|
+
test label? It builds, from the training partition only, a table of how
|
|
15
|
+
often each identifier value occurs with each class, and predicts a test
|
|
16
|
+
row's class distribution from its value's row of that table (additively
|
|
17
|
+
smoothed toward the training class prior). A test value never seen in
|
|
18
|
+
training gets the training class prior itself, i.e. no information. The
|
|
19
|
+
method uses only equality of values, so it does not depend on any
|
|
20
|
+
ordering or integer coding of the identifiers. It is a lookup, not a
|
|
21
|
+
model fit, so it uses the whole training partition, not a row sample.
|
|
22
|
+
|
|
23
|
+
Reported per column: seen_coverage (share of test rows whose value occurs
|
|
24
|
+
in training), standalone_auc (weighted one-vs-rest AUC over all test
|
|
25
|
+
rows, unseen ones scored with the prior) and seen_only_auc (the same AUC
|
|
26
|
+
on the seen rows only). The flag is on standalone_auc, which is the
|
|
27
|
+
shortcut's effect on this split as a whole; seen_only_auc shows how
|
|
28
|
+
strong the lookup is where it applies. Whether a column is associated
|
|
29
|
+
with the label for values never seen in training is a different question,
|
|
30
|
+
answered by a split grouped by that column, not by this check.
|
|
31
|
+
|
|
32
|
+
For a flagged column, details["by_class"] gives the one-vs-rest AUC of the
|
|
33
|
+
same lookup for each class of the attack-category (or label) column, so a
|
|
34
|
+
global AUC of 0.9 that is actually 0.99 for one attack family and 0.5 for
|
|
35
|
+
the rest is visible rather than averaged away.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import numpy as np
|
|
41
|
+
import pandas as pd
|
|
42
|
+
|
|
43
|
+
from . import _by_class
|
|
44
|
+
from ._auc import robust_auc
|
|
45
|
+
from ._status import NOT_APPLICABLE
|
|
46
|
+
|
|
47
|
+
# Standalone AUC above this on an identity column alone is a specific,
|
|
48
|
+
# checkable topology-shortcut signature, not just "IPs are informative."
|
|
49
|
+
AUC_FLAG_THRESHOLD = 0.8
|
|
50
|
+
# Fewer unique values than this (absolute) suggests the model could be
|
|
51
|
+
# memorizing a handful of attacker/victim hosts rather than a behavior.
|
|
52
|
+
LOW_CARDINALITY_THRESHOLD = 50
|
|
53
|
+
# Past this many classes the per-class breakdown is left out of the report.
|
|
54
|
+
MAX_CLASSES_FOR_BREAKDOWN = 25
|
|
55
|
+
# Pseudo-count of the additive smoothing toward the training class prior: a
|
|
56
|
+
# value seen once in training predicts half its own class, half the prior.
|
|
57
|
+
SMOOTHING = 1.0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _keys(train_col: pd.Series, test_col: pd.Series) -> tuple[np.ndarray, np.ndarray]:
|
|
61
|
+
# Numeric on both sides: compare as float64, exact for integers such as
|
|
62
|
+
# ports. Otherwise compare as strings, so train and test still match on
|
|
63
|
+
# value when one side loaded a column as int and the other as object.
|
|
64
|
+
if pd.api.types.is_numeric_dtype(train_col) and pd.api.types.is_numeric_dtype(test_col):
|
|
65
|
+
return train_col.to_numpy(dtype=float), test_col.to_numpy(dtype=float)
|
|
66
|
+
return train_col.astype(str).to_numpy(), test_col.astype(str).to_numpy()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def lookup_proba(train_keys: np.ndarray, train_y, test_keys: np.ndarray):
|
|
70
|
+
"""Class probabilities for each test row from a training-only value table.
|
|
71
|
+
|
|
72
|
+
Returns (classes, proba, seen): proba has one column per training class,
|
|
73
|
+
in sorted class order; seen marks test rows whose value occurs in
|
|
74
|
+
training. Unseen rows get the training class prior.
|
|
75
|
+
"""
|
|
76
|
+
key_codes, values = pd.factorize(train_keys, use_na_sentinel=False)
|
|
77
|
+
classes = np.asarray(sorted(pd.unique(np.asarray(train_y))))
|
|
78
|
+
y_codes = pd.Index(classes).get_indexer(np.asarray(train_y))
|
|
79
|
+
k = len(classes)
|
|
80
|
+
table = np.bincount(key_codes * k + y_codes, minlength=len(values) * k).reshape(len(values), k).astype(float)
|
|
81
|
+
prior = table.sum(axis=0) / table.sum()
|
|
82
|
+
smoothed = (table + SMOOTHING * prior) / (table.sum(axis=1, keepdims=True) + SMOOTHING)
|
|
83
|
+
idx = pd.Index(values).get_indexer(test_keys)
|
|
84
|
+
seen = idx >= 0
|
|
85
|
+
proba = np.empty((len(idx), k))
|
|
86
|
+
proba[seen] = smoothed[idx[seen]]
|
|
87
|
+
proba[~seen] = prior
|
|
88
|
+
return classes, proba, seen
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def check_predictive_power(train_df: pd.DataFrame, test_df: pd.DataFrame, cfg: dict) -> dict:
|
|
92
|
+
id_cols = [c for c in cfg["schema"]["id_like_columns"] if c in train_df.columns]
|
|
93
|
+
label_col = cfg["schema"]["label_column"]
|
|
94
|
+
if not id_cols:
|
|
95
|
+
return {
|
|
96
|
+
"check": "identity_column_flag", "status": NOT_APPLICABLE,
|
|
97
|
+
"summary": "no id_like_columns configured", "details": {},
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
auc_all, auc_seen, coverage, flagged, keys = {}, {}, {}, [], {}
|
|
101
|
+
y_test = test_df[label_col].to_numpy()
|
|
102
|
+
for col in id_cols:
|
|
103
|
+
train_keys, test_keys = _keys(train_df[col], test_df[col])
|
|
104
|
+
keys[col] = (train_keys, test_keys)
|
|
105
|
+
classes, proba, seen = lookup_proba(train_keys, train_df[label_col], test_keys)
|
|
106
|
+
coverage[col] = float(seen.mean()) if len(seen) else 0.0
|
|
107
|
+
auc_all[col] = robust_auc(y_test, proba, classes)
|
|
108
|
+
auc_seen[col] = robust_auc(y_test[seen], proba[seen], classes) if seen.any() else None
|
|
109
|
+
if auc_all[col] is not None and auc_all[col] > AUC_FLAG_THRESHOLD:
|
|
110
|
+
flagged.append(col)
|
|
111
|
+
|
|
112
|
+
def fmt(a):
|
|
113
|
+
return None if a is None else round(a, 3)
|
|
114
|
+
|
|
115
|
+
status = "flag" if flagged else "ok"
|
|
116
|
+
summary = "standalone AUC by column (seen-value coverage, AUC on seen values): " + ", ".join(
|
|
117
|
+
f"{c}: {fmt(auc_all[c])} ({coverage[c]:.1%}, {fmt(auc_seen[c])})" for c in id_cols
|
|
118
|
+
)
|
|
119
|
+
if flagged:
|
|
120
|
+
summary += f". Suggest dropping: {flagged}"
|
|
121
|
+
|
|
122
|
+
details = {"standalone_auc": auc_all, "seen_only_auc": auc_seen, "seen_coverage": coverage,
|
|
123
|
+
"suggested_drop": flagged, "method": "training-only value lookup, unseen values scored with the prior"}
|
|
124
|
+
if flagged:
|
|
125
|
+
details["by_class"] = {col: _by_class_auc(train_df, test_df, *keys[col], cfg) for col in flagged}
|
|
126
|
+
|
|
127
|
+
return {"check": "identity_column_flag", "status": status, "summary": summary, "details": details}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _by_class_auc(train_df: pd.DataFrame, test_df: pd.DataFrame, train_keys, test_keys, cfg: dict) -> dict | str:
|
|
131
|
+
group_col = _by_class.group_column(cfg)
|
|
132
|
+
classes = _by_class.eligible_classes(train_df, group_col)
|
|
133
|
+
if len(classes) > MAX_CLASSES_FOR_BREAKDOWN:
|
|
134
|
+
return f"skipped, {len(classes)} eligible classes exceeds the {MAX_CLASSES_FOR_BREAKDOWN}-class cap"
|
|
135
|
+
|
|
136
|
+
table_classes, proba, _ = lookup_proba(train_keys, train_df[group_col], test_keys)
|
|
137
|
+
position = {c: i for i, c in enumerate(table_classes)}
|
|
138
|
+
y_test = test_df[group_col].to_numpy()
|
|
139
|
+
by_class = {}
|
|
140
|
+
for cls in classes:
|
|
141
|
+
y_binary = y_test == cls
|
|
142
|
+
if not y_binary.any() or y_binary.all():
|
|
143
|
+
continue
|
|
144
|
+
score = proba[:, position[cls]]
|
|
145
|
+
by_class[str(cls)] = robust_auc(y_binary, np.column_stack([1 - score, score]), np.array([False, True]))
|
|
146
|
+
return by_class
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def check_cardinality(train_df: pd.DataFrame, cfg: dict) -> dict:
|
|
150
|
+
id_cols = [c for c in cfg["schema"]["id_like_columns"] if c in train_df.columns]
|
|
151
|
+
if not id_cols:
|
|
152
|
+
return {
|
|
153
|
+
"check": "low_cardinality_warning", "status": NOT_APPLICABLE,
|
|
154
|
+
"summary": "no id_like_columns configured", "details": {},
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
cardinalities = {c: int(train_df[c].nunique()) for c in id_cols}
|
|
158
|
+
flagged = [c for c, n in cardinalities.items() if n < LOW_CARDINALITY_THRESHOLD]
|
|
159
|
+
|
|
160
|
+
status = "warning" if flagged else "ok"
|
|
161
|
+
summary = f"unique values by column: {cardinalities}"
|
|
162
|
+
if flagged:
|
|
163
|
+
summary += (
|
|
164
|
+
f". {flagged} have fewer than {LOW_CARDINALITY_THRESHOLD} unique values, "
|
|
165
|
+
"risk of memorizing specific hosts rather than learning attack behavior"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
"check": "low_cardinality_warning", "status": status, "summary": summary,
|
|
170
|
+
"details": {"cardinalities": cardinalities, "flagged": flagged},
|
|
171
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Known-issue lookup (v2, opt-in): curated per-dataset documented problems.
|
|
2
|
+
|
|
3
|
+
Unlike schema_fingerprint_check (which matches the *extractor tool*'s
|
|
4
|
+
column-naming signature regardless of dataset), this matches on
|
|
5
|
+
dataset.name directly and surfaces problems specific to that dataset,
|
|
6
|
+
curation work, not a new algorithm. Needs no reference_dataset.
|
|
7
|
+
|
|
8
|
+
Extend KNOWN_ISSUES as more curated, citable findings are gathered.
|
|
9
|
+
Matching is substring-based against a lowercased dataset.name so
|
|
10
|
+
"cic-ids2018", "CIC_IDS2018", and "cic2018" all resolve the same way.
|
|
11
|
+
Matching by name cannot tell one release of a dataset from another (an original
|
|
12
|
+
CSV release from a corrected re-extraction of the same traffic), so an entry that
|
|
13
|
+
documents one release says which in its "release" field, and the summary prints
|
|
14
|
+
it; whether the issue applies to the audited files is for the reader to check.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import pandas as pd
|
|
20
|
+
|
|
21
|
+
KNOWN_ISSUES = {
|
|
22
|
+
"cic-ids2018": [
|
|
23
|
+
{
|
|
24
|
+
"issue": "In the original release, 172 of the 362 flows labeled Brute Force-Web "
|
|
25
|
+
"on 2018-02-23 are not part of the attack: 21 have malformed addresses "
|
|
26
|
+
"and 151 (41.7%) are background traffic, 128 of them UDP (126 to port 500) "
|
|
27
|
+
"(a ground-truth labeling error, not an extraction or pipeline bug).",
|
|
28
|
+
"citation": "Liu et al. 2022, IEEE CNS, companion documentation",
|
|
29
|
+
"release": "the original CSE-CIC-IDS2018 CSVs (UNB/AWS)",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"issue": "A full re-labeling of the original release found 7.53% of labels wrong, "
|
|
33
|
+
"with the error rate above 75% for some attack classes.",
|
|
34
|
+
"citation": "Liu et al. 2022, IEEE CNS (rates as summarized by Cantone et al. 2024)",
|
|
35
|
+
"release": "the original CSE-CIC-IDS2018 CSVs (UNB/AWS)",
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
"cic-ids2017": [
|
|
39
|
+
{
|
|
40
|
+
"issue": "The traffic capture has packet misorder and duplication, and some "
|
|
41
|
+
"attacks that were actually launched are not correctly labeled as "
|
|
42
|
+
"attack traffic in the released CSVs.",
|
|
43
|
+
"citation": "Engelen et al. 2021, IEEE S&P Workshops (WTMC)",
|
|
44
|
+
"release": "the original CIC-IDS2017 CSVs (UNB)",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"issue": "A full re-labeling of the original release found 6.67% of labels wrong, "
|
|
48
|
+
"with the error rate above 75% for some attack classes.",
|
|
49
|
+
"citation": "Liu et al. 2022, IEEE CNS (rates as summarized by Cantone et al. 2024)",
|
|
50
|
+
"release": "the original CIC-IDS2017 CSVs (UNB)",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"issue": "Heartbleed has only 11 rows (about 0.0004% of the dataset), too few to "
|
|
54
|
+
"evaluate reliably regardless of labeling accuracy.",
|
|
55
|
+
"citation": "Cantone et al. 2024, IEEE Access, Table 1",
|
|
56
|
+
"release": "the original CIC-IDS2017 CSVs and the corrected re-extraction alike",
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
"nsl-kdd": [
|
|
60
|
+
{
|
|
61
|
+
"issue": "The test set intentionally includes attack types absent from "
|
|
62
|
+
"the training set, by design, to test generalization to unknown "
|
|
63
|
+
"attacks rather than memorization; this project's own audit of "
|
|
64
|
+
"the official partition finds 17 such test-only attack types.",
|
|
65
|
+
"citation": "Tavallaee et al. 2009, IEEE CISDA",
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
"bot-iot": [
|
|
69
|
+
{
|
|
70
|
+
"issue": "The NF-BoT-IoT-V2 NetFlow conversion is over 99.9% attack "
|
|
71
|
+
"traffic (benign flows are 129,437 of 30,420,086 rows, 0.43%), "
|
|
72
|
+
"an order of magnitude more skewed than most NIDS datasets; a "
|
|
73
|
+
"uniform row-level sample may carry very few or zero benign "
|
|
74
|
+
"rows, and a classifier's accuracy on this data mostly reflects "
|
|
75
|
+
"its attack-vs-attack discrimination, not its ability to "
|
|
76
|
+
"recognize normal traffic.",
|
|
77
|
+
"citation": "Koroniotis et al. 2019, Future Generation Computer Systems",
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
"bot-iot-official": [
|
|
81
|
+
{
|
|
82
|
+
"issue": "The full original (non-NetFlow) release is over 99.9% attack "
|
|
83
|
+
"traffic (benign flows are 477 of 3,668,522 rows, 0.013%), even "
|
|
84
|
+
"more skewed than the NetFlow-V2 conversion of this dataset; a "
|
|
85
|
+
"classifier's accuracy on this data mostly reflects its "
|
|
86
|
+
"attack-vs-attack discrimination, not its ability to recognize "
|
|
87
|
+
"normal traffic.",
|
|
88
|
+
"citation": "Koroniotis et al. 2019, Future Generation Computer Systems",
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
"cic-ddos2019": [
|
|
92
|
+
{
|
|
93
|
+
"issue": "By design, the two collection days cover different attack sets: "
|
|
94
|
+
"the testing day includes several DDoS types absent from the "
|
|
95
|
+
"training day (e.g. DNS, NTP, SNMP, TFTP, WebDDoS reflection "
|
|
96
|
+
"attacks), so a classifier trained only on the training day has "
|
|
97
|
+
"never seen those attack types.",
|
|
98
|
+
"citation": "Sharafaldin et al. 2019, IEEE ICCST",
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
"issue": "The same attack type is labeled inconsistently between the two "
|
|
102
|
+
"days (e.g. training's 'UDPLag'/'MSSQL' vs. testing's 'UDP-lag'/"
|
|
103
|
+
"'DrDoS_MSSQL'), so label strings must be remapped before any "
|
|
104
|
+
"train/test comparison, not matched literally.",
|
|
105
|
+
"citation": "Independently verified in this project's own audit methodology",
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
"unsw-nb15": [
|
|
109
|
+
{
|
|
110
|
+
"issue": "Some redistributions invert the UNSW_NB15_training-set.csv/"
|
|
111
|
+
"testing-set.csv file-name-to-content mapping relative to the "
|
|
112
|
+
"dataset's documented convention (82,332 vs. 175,341 rows), "
|
|
113
|
+
"verify against the published per-attack-category counts before "
|
|
114
|
+
"trusting file names.",
|
|
115
|
+
"citation": "Independently verified in this project's own audit methodology",
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def check(train_df: pd.DataFrame, cfg: dict) -> dict:
|
|
122
|
+
name = (cfg["dataset"]["name"] or "").strip().lower()
|
|
123
|
+
matched_keys = [key for key in KNOWN_ISSUES if key in name]
|
|
124
|
+
# A key that is itself a substring of another matched key (e.g. "bot-iot" inside
|
|
125
|
+
# "bot-iot-official") is a less specific match for the same dataset name and is
|
|
126
|
+
# dropped in favor of the more specific one, so variant-specific curated numbers
|
|
127
|
+
# don't get mixed with a different variant's numbers under the same lookup.
|
|
128
|
+
matched_keys = [
|
|
129
|
+
key for key in matched_keys
|
|
130
|
+
if not any(key != other and key in other for other in matched_keys)
|
|
131
|
+
]
|
|
132
|
+
matches = [issue for key in matched_keys for issue in KNOWN_ISSUES[key]]
|
|
133
|
+
|
|
134
|
+
status = "warning" if matches else "ok"
|
|
135
|
+
# The issue text itself, not just a count - a scorecard reader shouldn't have to
|
|
136
|
+
# go open audit_report_*.json to find out what the curated problem actually is.
|
|
137
|
+
summary = (
|
|
138
|
+
"; ".join(
|
|
139
|
+
f"{m['issue']} ({'applies to ' + m['release'] + '; ' if m.get('release') else ''}{m['citation']})"
|
|
140
|
+
for m in matches
|
|
141
|
+
) if matches
|
|
142
|
+
else f"no curated known issues for dataset '{name}'"
|
|
143
|
+
)
|
|
144
|
+
return {"check": "known_issue_lookup", "status": status, "summary": summary, "details": {"matches": matches}}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Label-conflict check: identical features, contradictory labels.
|
|
2
|
+
|
|
3
|
+
Independently motivated from three directions: Deepchecks' "Conflicting
|
|
4
|
+
Labels" check (general ML), Northcutt et al. 2021's finding that label
|
|
5
|
+
errors average 3.3% across major ML benchmarks, and Wu & Keogh 2021's
|
|
6
|
+
"mislabeled ground truth" flaw in time-series anomaly benchmarks. A feature
|
|
7
|
+
vector mapped to more than one label is a stronger, more specific claim
|
|
8
|
+
than a duplicate. The ground truth contradicts itself, and no model
|
|
9
|
+
can get both instances right regardless of how well it memorizes.
|
|
10
|
+
|
|
11
|
+
Deliberately scoped like dedup_check: it must run on the RAW data, before
|
|
12
|
+
preprocessing.dedup. dataset.dedup() already compares on features only
|
|
13
|
+
(ignoring the label column, see dataset.dedup's own docstring), so a
|
|
14
|
+
conflicting-label group is exactly the kind of "duplicate" it collapses to
|
|
15
|
+
one arbitrarily-kept row - after dedup, every feature vector maps to
|
|
16
|
+
exactly one label by construction, and this check will correctly report
|
|
17
|
+
zero. That's not the conflict being fixed, just made unobservable; the
|
|
18
|
+
"before" pass is what actually shows it existed.
|
|
19
|
+
|
|
20
|
+
Also attaches a per-class breakdown (details["by_class"]) when flagged:
|
|
21
|
+
the global conflict rate can be driven almost entirely by one attack
|
|
22
|
+
category, invisible in the aggregate count alone.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import pandas as pd
|
|
28
|
+
|
|
29
|
+
from . import _by_class
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def check(train_df: pd.DataFrame, test_df: pd.DataFrame, cfg: dict) -> dict:
|
|
33
|
+
schema = cfg["schema"]
|
|
34
|
+
label_col = schema["label_column"]
|
|
35
|
+
group_col = _by_class.group_column(cfg)
|
|
36
|
+
ignore = set(schema["drop_columns"]) | {label_col}
|
|
37
|
+
if schema["attack_category_column"]:
|
|
38
|
+
ignore.add(schema["attack_category_column"])
|
|
39
|
+
compare_cols = [c for c in train_df.columns if c not in ignore]
|
|
40
|
+
carry_cols = [label_col] if group_col == label_col else [label_col, group_col]
|
|
41
|
+
|
|
42
|
+
combined = pd.concat(
|
|
43
|
+
[
|
|
44
|
+
train_df[[*compare_cols, *carry_cols]].assign(_split="train"),
|
|
45
|
+
test_df[[*compare_cols, *carry_cols]].assign(_split="test"),
|
|
46
|
+
],
|
|
47
|
+
ignore_index=True,
|
|
48
|
+
)
|
|
49
|
+
# Group on a single hashed key rather than all compare_cols directly: pandas'
|
|
50
|
+
# groupby builds an internal combined index proportional to the number of key
|
|
51
|
+
# columns, which turned out to actually exhaust memory on a real ~2.9M-row,
|
|
52
|
+
# 78-feature run (CIC-IDS2017) - a real OOM kill, not a hypothetical concern.
|
|
53
|
+
# Same hash function dataset.py's own content fingerprint already relies on for
|
|
54
|
+
# an equally big-stakes purpose (proving two runs used identical data), so a
|
|
55
|
+
# collision here is the same astronomically unlikely event, not a new risk.
|
|
56
|
+
combined["_key"] = pd.util.hash_pandas_object(combined[compare_cols], index=False).to_numpy()
|
|
57
|
+
combined["_n_labels"] = combined.groupby("_key", sort=False)[label_col].transform("nunique")
|
|
58
|
+
conflicting = combined[combined["_n_labels"] > 1]
|
|
59
|
+
|
|
60
|
+
conflicting_rows = len(conflicting)
|
|
61
|
+
if conflicting_rows == 0:
|
|
62
|
+
return {
|
|
63
|
+
"check": "label_conflict_check", "status": "ok",
|
|
64
|
+
"summary": "no feature vector maps to more than one label", "details": {},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
conflicting_groups = int(conflicting["_key"].nunique())
|
|
68
|
+
cross_split = int((conflicting.groupby("_key", sort=False)["_split"].nunique() > 1).sum())
|
|
69
|
+
pct = conflicting_rows / len(combined)
|
|
70
|
+
|
|
71
|
+
summary = (
|
|
72
|
+
f"{conflicting_groups:,} feature vector(s) ({conflicting_rows:,} rows, {pct:.2%}) map to more than "
|
|
73
|
+
f"one label. The ground truth contradicts itself for these rows"
|
|
74
|
+
)
|
|
75
|
+
if cross_split:
|
|
76
|
+
summary += f"; {cross_split:,} of these span train and test"
|
|
77
|
+
|
|
78
|
+
conflicting_mask = (combined["_n_labels"] > 1).to_numpy()
|
|
79
|
+
by_class = {}
|
|
80
|
+
for cls in _by_class.eligible_classes(combined, group_col):
|
|
81
|
+
cls_mask = (combined[group_col] == cls).to_numpy()
|
|
82
|
+
by_class[str(cls)] = {"conflicting_rate": float(conflicting_mask[cls_mask].mean())}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
"check": "label_conflict_check", "status": "flag", "summary": summary,
|
|
86
|
+
"details": {
|
|
87
|
+
"conflicting_groups": conflicting_groups,
|
|
88
|
+
"conflicting_rows": conflicting_rows,
|
|
89
|
+
"cross_split_conflicting_groups": cross_split,
|
|
90
|
+
"by_class": by_class,
|
|
91
|
+
},
|
|
92
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Feature-importance-driven leakage screen (paper Section 3.2).
|
|
2
|
+
|
|
3
|
+
Fits a fast RandomForest, inspects its own feature_importances_ rather
|
|
4
|
+
than guessing candidate leakage columns from domain knowledge, and flags
|
|
5
|
+
a single feature or feature pair carrying a large majority of total
|
|
6
|
+
split importance. Same method that found real leakage features in
|
|
7
|
+
CIC-IDS2018 (Dst Port, Init Win Bytes, FIN Flag Count).
|
|
8
|
+
|
|
9
|
+
When flagged, also attaches a per-class breakdown (details["by_class"]):
|
|
10
|
+
one-vs-rest AUC of the flagged feature's raw values against each class,
|
|
11
|
+
via roc_auc_score directly rather than fitting one classifier per class -
|
|
12
|
+
a numeric feature's own values already are the score, no model needed.
|
|
13
|
+
A class where the feature separates as well as the global fit and one
|
|
14
|
+
where it doesn't tells a different story than the aggregate hides.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import pandas as pd
|
|
20
|
+
from sklearn.ensemble import RandomForestClassifier
|
|
21
|
+
from sklearn.metrics import roc_auc_score
|
|
22
|
+
|
|
23
|
+
from ..data import features
|
|
24
|
+
from . import _by_class
|
|
25
|
+
from ._auc import robust_auc
|
|
26
|
+
|
|
27
|
+
# A single feature carrying more than this share of total split
|
|
28
|
+
# importance is flagged as a specific, checkable shortcut-learning signature.
|
|
29
|
+
TOP1_SHARE_THRESHOLD = 0.5
|
|
30
|
+
TOP2_SHARE_THRESHOLD = 0.7
|
|
31
|
+
MAX_FIT_ROWS = 200_000
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def check(train_df: pd.DataFrame, test_df: pd.DataFrame, cfg: dict, seed: int = 0) -> dict:
|
|
35
|
+
"""seed varies the sample draw and the classifier's own randomness. The
|
|
36
|
+
default single run is called with cfg["random_seed"] (run_audit's job);
|
|
37
|
+
seed_sensitivity_check separately sweeps its own fixed range of seeds to
|
|
38
|
+
test whether the flag/ok conclusion holds up regardless of that one value.
|
|
39
|
+
"""
|
|
40
|
+
label_col = cfg["schema"]["label_column"]
|
|
41
|
+
cols = features.feature_columns(train_df, cfg)
|
|
42
|
+
train_fit = train_df.sample(n=min(len(train_df), MAX_FIT_ROWS), random_state=seed)
|
|
43
|
+
test_fit = test_df
|
|
44
|
+
|
|
45
|
+
x_train, x_test = features.encode_aligned(train_fit, test_fit, cols, unseen="missing")
|
|
46
|
+
y_train, y_test = train_fit[label_col], test_fit[label_col]
|
|
47
|
+
|
|
48
|
+
clf = RandomForestClassifier(n_estimators=100, random_state=seed, n_jobs=-1)
|
|
49
|
+
clf.fit(x_train, y_train)
|
|
50
|
+
|
|
51
|
+
importances = pd.Series(clf.feature_importances_, index=cols).sort_values(ascending=False)
|
|
52
|
+
top1_share = float(importances.iloc[0])
|
|
53
|
+
top2_share = float(importances.iloc[:2].sum())
|
|
54
|
+
|
|
55
|
+
flagged = top1_share > TOP1_SHARE_THRESHOLD or top2_share > TOP2_SHARE_THRESHOLD
|
|
56
|
+
details = {
|
|
57
|
+
"top_features": importances.head(5).to_dict(),
|
|
58
|
+
"top1_share": top1_share,
|
|
59
|
+
"top2_share": top2_share,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if flagged:
|
|
63
|
+
top_feature = importances.index[0]
|
|
64
|
+
standalone_auc = _standalone_auc(x_train[[top_feature]], y_train, x_test[[top_feature]], y_test, seed)
|
|
65
|
+
details["standalone_auc"] = {top_feature: standalone_auc}
|
|
66
|
+
details["by_class"] = _by_class_auc(train_fit, x_train[top_feature].to_numpy(), cfg)
|
|
67
|
+
auc_text = "n/a" if standalone_auc is None else f"{standalone_auc:.3f}"
|
|
68
|
+
summary = (
|
|
69
|
+
f"'{top_feature}' carries {top1_share:.1%} of total split importance "
|
|
70
|
+
f"(standalone AUC={auc_text}). Check whether it's a leakage "
|
|
71
|
+
f"artifact rather than attack-behavior signal"
|
|
72
|
+
)
|
|
73
|
+
status = "flag"
|
|
74
|
+
else:
|
|
75
|
+
summary = f"no single feature or pair dominates importance (top1={top1_share:.1%}, top2={top2_share:.1%})"
|
|
76
|
+
status = "ok"
|
|
77
|
+
|
|
78
|
+
return {"check": "leakage_screen", "status": status, "summary": summary, "details": details}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _standalone_auc(x_train_col, y_train, x_test_col, y_test, seed: int = 0) -> float | None:
|
|
82
|
+
clf = RandomForestClassifier(n_estimators=50, random_state=seed, n_jobs=-1)
|
|
83
|
+
clf.fit(x_train_col, y_train)
|
|
84
|
+
return robust_auc(y_test, clf.predict_proba(x_test_col), clf.classes_)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _by_class_auc(train_fit: pd.DataFrame, feature_values, cfg: dict) -> dict:
|
|
88
|
+
group_col = _by_class.group_column(cfg)
|
|
89
|
+
by_class = {}
|
|
90
|
+
for cls in _by_class.eligible_classes(train_fit, group_col):
|
|
91
|
+
y_binary = (train_fit[group_col] == cls).to_numpy()
|
|
92
|
+
if not y_binary.any() or y_binary.all():
|
|
93
|
+
continue
|
|
94
|
+
auc = roc_auc_score(y_binary, feature_values)
|
|
95
|
+
# A feature can separate a class either by high values or low values -
|
|
96
|
+
# max() with the inverse reports separability regardless of direction,
|
|
97
|
+
# same convention as a rule-based threshold could use either side.
|
|
98
|
+
by_class[str(cls)] = float(max(auc, 1 - auc))
|
|
99
|
+
return by_class
|