tabaudit 0.1.0__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.
- tabaudit/__init__.py +8 -0
- tabaudit/audit.py +102 -0
- tabaudit/checks/__init__.py +22 -0
- tabaudit/checks/duplicates.py +83 -0
- tabaudit/checks/imbalance.py +70 -0
- tabaudit/checks/label_noise.py +134 -0
- tabaudit/checks/leakage.py +316 -0
- tabaudit/checks/schema.py +119 -0
- tabaudit/cli.py +155 -0
- tabaudit/context.py +52 -0
- tabaudit/demo.py +111 -0
- tabaudit/findings.py +122 -0
- tabaudit/loader.py +88 -0
- tabaudit/report/__init__.py +4 -0
- tabaudit/report/console.py +155 -0
- tabaudit/report/html.py +35 -0
- tabaudit/report/template.html +139 -0
- tabaudit-0.1.0.dist-info/METADATA +229 -0
- tabaudit-0.1.0.dist-info/RECORD +23 -0
- tabaudit-0.1.0.dist-info/WHEEL +5 -0
- tabaudit-0.1.0.dist-info/entry_points.txt +2 -0
- tabaudit-0.1.0.dist-info/licenses/LICENSE +21 -0
- tabaudit-0.1.0.dist-info/top_level.txt +1 -0
tabaudit/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""tabaudit — audit tabular ML datasets before you train."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from tabaudit.audit import run_audit
|
|
6
|
+
from tabaudit.findings import AuditReport, Finding, Severity
|
|
7
|
+
|
|
8
|
+
__all__ = ["AuditReport", "Finding", "Severity", "__version__", "run_audit"]
|
tabaudit/audit.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Orchestrates loading, task inference and running every registered check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable, Iterable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
from tabaudit import __version__
|
|
12
|
+
from tabaudit.checks import REGISTRY
|
|
13
|
+
from tabaudit.context import AuditContext
|
|
14
|
+
from tabaudit.findings import AuditReport, CheckRun, DatasetSummary, Finding
|
|
15
|
+
from tabaudit.loader import dtype_kinds, infer_task, load_table
|
|
16
|
+
|
|
17
|
+
ProgressFn = Callable[[str, str], None] # (check_name, status) -> None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_audit(
|
|
21
|
+
data: str | Path | pd.DataFrame,
|
|
22
|
+
target: str | None = None,
|
|
23
|
+
test: str | Path | pd.DataFrame | None = None,
|
|
24
|
+
checks: Iterable[str] | None = None,
|
|
25
|
+
max_rows: int = 50_000,
|
|
26
|
+
random_state: int = 42,
|
|
27
|
+
on_progress: ProgressFn | None = None,
|
|
28
|
+
) -> AuditReport:
|
|
29
|
+
"""Audit a dataset and return an :class:`AuditReport`.
|
|
30
|
+
|
|
31
|
+
Parameters
|
|
32
|
+
----------
|
|
33
|
+
data: path to CSV/Parquet/… or a DataFrame.
|
|
34
|
+
target: name of the label column (omit for unsupervised checks only).
|
|
35
|
+
test: optional held-out set, used to detect train/test contamination.
|
|
36
|
+
checks: subset of check names to run (default: all).
|
|
37
|
+
"""
|
|
38
|
+
df, path = (
|
|
39
|
+
(data, "<DataFrame>") if isinstance(data, pd.DataFrame) else (load_table(data), str(data))
|
|
40
|
+
)
|
|
41
|
+
test_df, test_path = (None, None)
|
|
42
|
+
if test is not None:
|
|
43
|
+
test_df, test_path = (
|
|
44
|
+
(test, "<DataFrame>")
|
|
45
|
+
if isinstance(test, pd.DataFrame)
|
|
46
|
+
else (load_table(test), str(test))
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if target is not None and target not in df.columns:
|
|
50
|
+
close = [c for c in df.columns if c.lower() == target.lower()]
|
|
51
|
+
hint = f" Did you mean '{close[0]}'?" if close else ""
|
|
52
|
+
raise KeyError(f"Target column '{target}' not found.{hint}")
|
|
53
|
+
|
|
54
|
+
task = infer_task(df[target]) if target else "unsupervised"
|
|
55
|
+
n_classes = (
|
|
56
|
+
int(df[target].nunique(dropna=True)) if target and task == "classification" else None
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
ctx = AuditContext(
|
|
60
|
+
df=df,
|
|
61
|
+
target=target,
|
|
62
|
+
task=task,
|
|
63
|
+
test_df=test_df,
|
|
64
|
+
max_rows=max_rows,
|
|
65
|
+
random_state=random_state,
|
|
66
|
+
)
|
|
67
|
+
selected = list(checks) if checks else list(REGISTRY)
|
|
68
|
+
unknown = [c for c in selected if c not in REGISTRY]
|
|
69
|
+
if unknown:
|
|
70
|
+
raise ValueError(f"Unknown check(s): {unknown}. Available: {list(REGISTRY)}")
|
|
71
|
+
|
|
72
|
+
findings: list[Finding] = []
|
|
73
|
+
runs: list[CheckRun] = []
|
|
74
|
+
for name in selected:
|
|
75
|
+
fn = REGISTRY[name]
|
|
76
|
+
if on_progress:
|
|
77
|
+
on_progress(name, "running")
|
|
78
|
+
t0 = time.perf_counter()
|
|
79
|
+
try:
|
|
80
|
+
out = fn(ctx)
|
|
81
|
+
findings.extend(out)
|
|
82
|
+
runs.append(CheckRun(name, "ok", time.perf_counter() - t0, len(out)))
|
|
83
|
+
except Exception as exc:
|
|
84
|
+
runs.append(
|
|
85
|
+
CheckRun(name, "error", time.perf_counter() - t0, 0, f"{type(exc).__name__}: {exc}")
|
|
86
|
+
)
|
|
87
|
+
if on_progress:
|
|
88
|
+
on_progress(name, runs[-1].status)
|
|
89
|
+
|
|
90
|
+
summary = DatasetSummary(
|
|
91
|
+
path=path,
|
|
92
|
+
n_rows=len(df),
|
|
93
|
+
n_cols=int(df.shape[1]),
|
|
94
|
+
target=target,
|
|
95
|
+
task=task,
|
|
96
|
+
n_classes=n_classes,
|
|
97
|
+
test_path=test_path,
|
|
98
|
+
n_test_rows=len(test_df) if test_df is not None else None,
|
|
99
|
+
memory_mb=round(float(df.memory_usage(deep=True).sum()) / 1e6, 2),
|
|
100
|
+
dtypes=dtype_kinds(df),
|
|
101
|
+
)
|
|
102
|
+
return AuditReport(summary=summary, findings=findings, checks=runs, version=__version__)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Check registry. Each check is a callable (AuditContext) -> list[Finding]."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
from tabaudit.checks import duplicates, imbalance, label_noise, leakage, schema
|
|
8
|
+
from tabaudit.context import AuditContext
|
|
9
|
+
from tabaudit.findings import Finding
|
|
10
|
+
|
|
11
|
+
CheckFn = Callable[[AuditContext], list[Finding]]
|
|
12
|
+
|
|
13
|
+
# Ordered: cheap structural checks first, model-based checks last.
|
|
14
|
+
REGISTRY: dict[str, CheckFn] = {
|
|
15
|
+
"schema": schema.run,
|
|
16
|
+
"duplicates": duplicates.run,
|
|
17
|
+
"imbalance": imbalance.run,
|
|
18
|
+
"leakage": leakage.run,
|
|
19
|
+
"label_noise": label_noise.run,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
__all__ = ["REGISTRY", "CheckFn"]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Duplicate rows, conflicting labels, and train/test contamination."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
from tabaudit.context import AuditContext
|
|
8
|
+
from tabaudit.findings import Finding, Severity
|
|
9
|
+
|
|
10
|
+
CHECK = "duplicates"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _row_hashes(df: pd.DataFrame) -> pd.Series:
|
|
14
|
+
return pd.util.hash_pandas_object(df, index=False)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run(ctx: AuditContext) -> list[Finding]:
|
|
18
|
+
df = ctx.df
|
|
19
|
+
findings: list[Finding] = []
|
|
20
|
+
n = len(df)
|
|
21
|
+
feats = ctx.feature_cols
|
|
22
|
+
|
|
23
|
+
# ---- exact duplicate rows (all columns) -----------------------------
|
|
24
|
+
full_dup = df.duplicated(keep="first")
|
|
25
|
+
n_full = int(full_dup.sum())
|
|
26
|
+
if n_full:
|
|
27
|
+
frac = n_full / n
|
|
28
|
+
sev = Severity.HIGH if frac >= 0.05 else Severity.MEDIUM if frac >= 0.01 else Severity.LOW
|
|
29
|
+
findings.append(
|
|
30
|
+
Finding(
|
|
31
|
+
check=CHECK,
|
|
32
|
+
severity=sev,
|
|
33
|
+
title=f"{n_full:,} exact duplicate rows ({frac:.1%})",
|
|
34
|
+
detail="Identical rows will land in both train and validation folds, "
|
|
35
|
+
"inflating every cross-validation metric.",
|
|
36
|
+
recommendation="`df.drop_duplicates()` before splitting.",
|
|
37
|
+
evidence={"n_duplicates": n_full, "fraction": round(frac, 4)},
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# ---- same features, different label --------------------------------
|
|
42
|
+
if ctx.target and feats:
|
|
43
|
+
fh = _row_hashes(df[feats])
|
|
44
|
+
grp = df.groupby(fh.values)[ctx.target].nunique(dropna=False)
|
|
45
|
+
conflicting = grp[grp > 1]
|
|
46
|
+
if not conflicting.empty:
|
|
47
|
+
n_rows = int(fh.isin(conflicting.index).sum())
|
|
48
|
+
findings.append(
|
|
49
|
+
Finding(
|
|
50
|
+
check=CHECK,
|
|
51
|
+
severity=Severity.MEDIUM,
|
|
52
|
+
title=f"{len(conflicting):,} feature-identical group(s) carry conflicting labels",
|
|
53
|
+
detail=f"{n_rows:,} rows share identical features with another row but have a "
|
|
54
|
+
"different target value. No model can fit these; they cap achievable accuracy.",
|
|
55
|
+
recommendation="Investigate the labelling process, or add the feature that "
|
|
56
|
+
"actually distinguishes them.",
|
|
57
|
+
evidence={"n_groups": len(conflicting), "n_rows": n_rows},
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# ---- train/test overlap --------------------------------------------
|
|
62
|
+
if ctx.test_df is not None:
|
|
63
|
+
common = [c for c in feats if c in ctx.test_df.columns]
|
|
64
|
+
if common:
|
|
65
|
+
train_h = set(_row_hashes(df[common]))
|
|
66
|
+
test_h = _row_hashes(ctx.test_df[common])
|
|
67
|
+
n_leak = int(test_h.isin(train_h).sum())
|
|
68
|
+
if n_leak:
|
|
69
|
+
frac = n_leak / len(ctx.test_df)
|
|
70
|
+
sev = Severity.CRITICAL if frac >= 0.01 else Severity.HIGH
|
|
71
|
+
findings.append(
|
|
72
|
+
Finding(
|
|
73
|
+
check=CHECK,
|
|
74
|
+
severity=sev,
|
|
75
|
+
title=f"{n_leak:,} test rows ({frac:.1%}) also appear in the training set",
|
|
76
|
+
detail="The model has already seen these rows. Any test-set score is "
|
|
77
|
+
"partly memorisation, not generalisation.",
|
|
78
|
+
recommendation="Remove overlapping rows from the test set, or re-split "
|
|
79
|
+
"with a group-aware splitter if rows belong to entities.",
|
|
80
|
+
evidence={"n_overlap": n_leak, "fraction_of_test": round(frac, 4)},
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
return findings
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Class imbalance and rare classes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from tabaudit.context import AuditContext
|
|
6
|
+
from tabaudit.findings import Finding, Severity
|
|
7
|
+
|
|
8
|
+
CHECK = "imbalance"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run(ctx: AuditContext) -> list[Finding]:
|
|
12
|
+
if ctx.task != "classification" or ctx.y is None:
|
|
13
|
+
return []
|
|
14
|
+
y = ctx.y.dropna()
|
|
15
|
+
counts = y.value_counts()
|
|
16
|
+
if len(counts) < 2:
|
|
17
|
+
return [
|
|
18
|
+
Finding(
|
|
19
|
+
check=CHECK,
|
|
20
|
+
severity=Severity.CRITICAL,
|
|
21
|
+
title="Target has only one class",
|
|
22
|
+
detail=f"All {len(y):,} labelled rows are '{counts.index[0]}'.",
|
|
23
|
+
recommendation="This is not a classification dataset as-is.",
|
|
24
|
+
columns=[ctx.target or ""],
|
|
25
|
+
)
|
|
26
|
+
]
|
|
27
|
+
dist = {str(k): int(v) for k, v in counts.items()}
|
|
28
|
+
minority_frac = float(counts.min() / counts.sum())
|
|
29
|
+
ratio = float(counts.max() / counts.min())
|
|
30
|
+
findings: list[Finding] = []
|
|
31
|
+
|
|
32
|
+
sev: Severity | None
|
|
33
|
+
if ratio >= 100:
|
|
34
|
+
sev = Severity.HIGH
|
|
35
|
+
elif ratio >= 10:
|
|
36
|
+
sev = Severity.MEDIUM
|
|
37
|
+
elif ratio >= 3:
|
|
38
|
+
sev = Severity.LOW
|
|
39
|
+
else:
|
|
40
|
+
sev = None
|
|
41
|
+
if sev is not None:
|
|
42
|
+
findings.append(
|
|
43
|
+
Finding(
|
|
44
|
+
check=CHECK,
|
|
45
|
+
severity=sev,
|
|
46
|
+
title=f"Class imbalance {ratio:,.0f}:1 (minority class = {minority_frac:.2%})",
|
|
47
|
+
detail="Accuracy is meaningless here - a model predicting the majority class "
|
|
48
|
+
f"scores {1 - minority_frac:.1%} without learning anything.",
|
|
49
|
+
recommendation="Report precision/recall, PR-AUC or balanced accuracy; use "
|
|
50
|
+
"stratified splits and class weights. Never oversample before splitting.",
|
|
51
|
+
columns=[ctx.target or ""],
|
|
52
|
+
evidence={"class_counts": dist, "imbalance_ratio": round(ratio, 2)},
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
rare = counts[counts < 10]
|
|
57
|
+
if not rare.empty:
|
|
58
|
+
findings.append(
|
|
59
|
+
Finding(
|
|
60
|
+
check=CHECK,
|
|
61
|
+
severity=Severity.MEDIUM,
|
|
62
|
+
title=f"{len(rare)} class(es) have fewer than 10 examples",
|
|
63
|
+
detail=", ".join(f"'{k}' ({v})" for k, v in rare.items()),
|
|
64
|
+
recommendation="Merge into an 'other' class or collect more data; "
|
|
65
|
+
"stratified CV will fail or be meaningless for these.",
|
|
66
|
+
columns=[ctx.target or ""],
|
|
67
|
+
evidence={"rare_classes": {str(k): int(v) for k, v in rare.items()}},
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
return findings
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Likely mislabeled rows, found with confident learning (cleanlab).
|
|
2
|
+
|
|
3
|
+
We train an out-of-fold gradient-boosting model, obtain predicted class probabilities for
|
|
4
|
+
every row, and let cleanlab compare them with the given labels. Rows whose given label is
|
|
5
|
+
confidently contradicted by the model are flagged.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import warnings
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pandas as pd
|
|
14
|
+
from sklearn.ensemble import HistGradientBoostingClassifier
|
|
15
|
+
from sklearn.model_selection import StratifiedKFold, cross_val_predict
|
|
16
|
+
|
|
17
|
+
from tabaudit.context import AuditContext
|
|
18
|
+
from tabaudit.findings import Finding, Severity
|
|
19
|
+
|
|
20
|
+
CHECK = "label_noise"
|
|
21
|
+
TOP_N = 25
|
|
22
|
+
# A suspect counts as *likely* mislabeled when the out-of-fold model gives the given label
|
|
23
|
+
# less than this probability. Chosen on the synthetic benchmark (examples/validate_label_noise.py)
|
|
24
|
+
# as the point where precision is useful without collapsing recall.
|
|
25
|
+
LIKELY_MAX_SELF_CONFIDENCE = 0.2
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def make_model(random_state: int) -> HistGradientBoostingClassifier:
|
|
29
|
+
"""Deliberately regularised: an over-confident model makes cleanlab over-flag."""
|
|
30
|
+
return HistGradientBoostingClassifier(
|
|
31
|
+
max_iter=200,
|
|
32
|
+
learning_rate=0.05,
|
|
33
|
+
max_leaf_nodes=15,
|
|
34
|
+
min_samples_leaf=40,
|
|
35
|
+
l2_regularization=1.0,
|
|
36
|
+
early_stopping=True,
|
|
37
|
+
validation_fraction=0.15,
|
|
38
|
+
random_state=random_state,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def run(ctx: AuditContext) -> list[Finding]:
|
|
43
|
+
if ctx.task != "classification" or ctx.y is None:
|
|
44
|
+
return []
|
|
45
|
+
try:
|
|
46
|
+
from cleanlab.filter import find_label_issues
|
|
47
|
+
except ImportError: # pragma: no cover
|
|
48
|
+
return []
|
|
49
|
+
|
|
50
|
+
idx = ctx.sample_index()
|
|
51
|
+
# Leaky / identifier columns are excluded on purpose: a leak would make the model agree
|
|
52
|
+
# with every wrong label and hide the noise we are trying to find.
|
|
53
|
+
Xe = ctx.X_encoded_clean.loc[idx]
|
|
54
|
+
y = ctx.y.loc[idx]
|
|
55
|
+
keep = y.notna().to_numpy()
|
|
56
|
+
Xe, y = Xe[keep], y[keep]
|
|
57
|
+
if len(y) < 50 or Xe.shape[1] == 0:
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
y_codes, classes = pd.factorize(y)
|
|
61
|
+
counts = np.bincount(y_codes)
|
|
62
|
+
if len(classes) < 2 or counts.min() < 5:
|
|
63
|
+
return []
|
|
64
|
+
n_splits = int(min(5, counts.min()))
|
|
65
|
+
|
|
66
|
+
model = make_model(ctx.random_state)
|
|
67
|
+
cv = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=ctx.random_state)
|
|
68
|
+
with warnings.catch_warnings():
|
|
69
|
+
warnings.simplefilter("ignore")
|
|
70
|
+
pred_probs = cross_val_predict(model, Xe.to_numpy(), y_codes, cv=cv, method="predict_proba")
|
|
71
|
+
# n_jobs=1: cleanlab's worker pool misbehaves on Windows and buys nothing at this size.
|
|
72
|
+
suspected = find_label_issues(
|
|
73
|
+
labels=y_codes, pred_probs=pred_probs, filter_by="confident_learning", n_jobs=1
|
|
74
|
+
)
|
|
75
|
+
self_conf = pred_probs[np.arange(len(y_codes)), y_codes]
|
|
76
|
+
likely = suspected & (self_conf < LIKELY_MAX_SELF_CONFIDENCE)
|
|
77
|
+
# Rank all suspects by how little the model believes the given label.
|
|
78
|
+
issue_idx = np.flatnonzero(suspected)
|
|
79
|
+
issue_idx = issue_idx[np.argsort(self_conf[issue_idx])]
|
|
80
|
+
|
|
81
|
+
n_suspected, n_likely = int(suspected.sum()), int(likely.sum())
|
|
82
|
+
if n_suspected == 0:
|
|
83
|
+
return []
|
|
84
|
+
frac_likely = n_likely / len(y)
|
|
85
|
+
frac_suspected = n_suspected / len(y)
|
|
86
|
+
if frac_likely >= 0.08:
|
|
87
|
+
sev = Severity.HIGH
|
|
88
|
+
elif frac_likely >= 0.03:
|
|
89
|
+
sev = Severity.MEDIUM
|
|
90
|
+
elif frac_likely >= 0.005:
|
|
91
|
+
sev = Severity.LOW
|
|
92
|
+
else:
|
|
93
|
+
sev = Severity.INFO
|
|
94
|
+
|
|
95
|
+
# Top suspects, for the report.
|
|
96
|
+
suspects = []
|
|
97
|
+
for i in issue_idx[:TOP_N]:
|
|
98
|
+
given = y_codes[i]
|
|
99
|
+
suggested = int(np.argmax(pred_probs[i]))
|
|
100
|
+
suspects.append(
|
|
101
|
+
{
|
|
102
|
+
"row": int(Xe.index[i]),
|
|
103
|
+
"given_label": str(classes[given]),
|
|
104
|
+
"suggested_label": str(classes[suggested]),
|
|
105
|
+
"confidence_in_given": round(float(pred_probs[i, given]), 3),
|
|
106
|
+
"confidence_in_suggested": round(float(pred_probs[i, suggested]), 3),
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
scope = f" (of a {len(y):,}-row sample)" if len(idx) < len(ctx.df) else ""
|
|
110
|
+
excluded = sorted(ctx.excluded_features & set(ctx.X_encoded.columns))
|
|
111
|
+
if excluded:
|
|
112
|
+
scope += f"; estimated with {', '.join(excluded)} excluded as leaky/identifier"
|
|
113
|
+
return [
|
|
114
|
+
Finding(
|
|
115
|
+
check=CHECK,
|
|
116
|
+
severity=sev,
|
|
117
|
+
title=f"~{n_likely:,} rows ({frac_likely:.1%}) are likely mislabeled, "
|
|
118
|
+
f"{n_suspected - n_likely:,} more suspected",
|
|
119
|
+
detail="An out-of-fold model confidently disagrees with the given label on these rows"
|
|
120
|
+
f"{scope}. 'Likely' = model gives the given label <{LIKELY_MAX_SELF_CONFIDENCE:.0%} "
|
|
121
|
+
"probability; 'suspected' = flagged by confident learning (cleanlab). Label noise "
|
|
122
|
+
"caps achievable accuracy and misleads model selection.",
|
|
123
|
+
recommendation="Review the top suspects below (ranked most-confident first). If they are "
|
|
124
|
+
"real errors, relabel or drop them; do not silently trust the reported test accuracy.",
|
|
125
|
+
columns=[ctx.target or ""],
|
|
126
|
+
evidence={
|
|
127
|
+
"n_likely": n_likely,
|
|
128
|
+
"n_suspected": n_suspected,
|
|
129
|
+
"fraction_likely": round(frac_likely, 4),
|
|
130
|
+
"fraction_suspected": round(frac_suspected, 4),
|
|
131
|
+
"top_suspects": suspects,
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
]
|