datasets-lint 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.
- datasets_lint/__init__.py +21 -0
- datasets_lint/baseline.py +71 -0
- datasets_lint/checks/__init__.py +18 -0
- datasets_lint/checks/base.py +38 -0
- datasets_lint/checks/dup.py +187 -0
- datasets_lint/checks/pii.py +134 -0
- datasets_lint/checks/stats.py +96 -0
- datasets_lint/cli.py +168 -0
- datasets_lint/config.py +64 -0
- datasets_lint/dataset.py +207 -0
- datasets_lint/engine.py +55 -0
- datasets_lint/report/__init__.py +8 -0
- datasets_lint/report/html_out.py +106 -0
- datasets_lint/report/json_out.py +17 -0
- datasets_lint/report/templates/report.html.j2 +124 -0
- datasets_lint/result.py +110 -0
- datasets_lint-0.1.0.dist-info/METADATA +159 -0
- datasets_lint-0.1.0.dist-info/RECORD +21 -0
- datasets_lint-0.1.0.dist-info/WHEEL +4 -0
- datasets_lint-0.1.0.dist-info/entry_points.txt +2 -0
- datasets_lint-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""datasets-lint — ruff for training data.
|
|
2
|
+
|
|
3
|
+
Audit a dataset for duplicates, PII, distribution problems and eval-set
|
|
4
|
+
contamination. Runs offline, on CPU, and exits non-zero when thresholds
|
|
5
|
+
are breached so it can gate CI.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
from datasets_lint.dataset import Dataset, load_dataset # noqa: E402
|
|
11
|
+
from datasets_lint.result import CheckResult, Finding, Report, Severity # noqa: E402
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"__version__",
|
|
15
|
+
"CheckResult",
|
|
16
|
+
"Dataset",
|
|
17
|
+
"Finding",
|
|
18
|
+
"Report",
|
|
19
|
+
"Severity",
|
|
20
|
+
"load_dataset",
|
|
21
|
+
]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Compare a run against a previous JSON report (`--baseline`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from datasets_lint.result import Report, Severity
|
|
10
|
+
|
|
11
|
+
_RANK = {"ok": 0, "warn": 1, "error": 2}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_baseline(path: str | Path) -> dict[str, Any]:
|
|
15
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
16
|
+
if "checks" not in data:
|
|
17
|
+
raise ValueError(f"{path} is not a datasets-lint JSON report")
|
|
18
|
+
return data
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _num(value: Any) -> float | None:
|
|
22
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
23
|
+
return None
|
|
24
|
+
return float(value)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def diff_lines(report: Report, baseline: dict[str, Any]) -> list[str]:
|
|
28
|
+
"""Human-readable deltas: `dup.near_dup_pct 18.2 → 12.0 (-6.2) better`."""
|
|
29
|
+
old = {c.get("id"): c for c in baseline.get("checks", [])}
|
|
30
|
+
lines: list[str] = []
|
|
31
|
+
|
|
32
|
+
old_rows = baseline.get("dataset", {}).get("audited_rows")
|
|
33
|
+
if isinstance(old_rows, int) and old_rows != report.audited_rows:
|
|
34
|
+
lines.append(f"audited rows: {old_rows:,} → {report.audited_rows:,}")
|
|
35
|
+
|
|
36
|
+
for result in report.results:
|
|
37
|
+
prev = old.get(result.check_id)
|
|
38
|
+
if not prev:
|
|
39
|
+
lines.append(f"{result.check_id}: new check (no baseline)")
|
|
40
|
+
continue
|
|
41
|
+
|
|
42
|
+
prev_sev = str(prev.get("severity", "ok"))
|
|
43
|
+
now_sev = result.severity.value
|
|
44
|
+
if _RANK[now_sev] != _RANK.get(prev_sev, 0):
|
|
45
|
+
arrow = "better" if _RANK[now_sev] < _RANK.get(prev_sev, 0) else "WORSE"
|
|
46
|
+
lines.append(f"{result.check_id}: severity {prev_sev} → {now_sev} ({arrow})")
|
|
47
|
+
|
|
48
|
+
prev_metrics = prev.get("metrics", {})
|
|
49
|
+
for key, value in result.metrics.items():
|
|
50
|
+
before = _num(prev_metrics.get(key))
|
|
51
|
+
after = _num(value)
|
|
52
|
+
if before is None or after is None or before == after:
|
|
53
|
+
continue
|
|
54
|
+
delta = after - before
|
|
55
|
+
lines.append(
|
|
56
|
+
f"{result.check_id}.{key}: {before:,.3f} → {after:,.3f} "
|
|
57
|
+
f"({delta:+,.3f}) {'better' if delta < 0 else 'worse'}"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if not lines:
|
|
61
|
+
lines.append("no metric changes against baseline")
|
|
62
|
+
return lines
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def verdict_delta(report: Report, baseline: dict[str, Any]) -> Severity:
|
|
66
|
+
"""Return the baseline verdict if the run did not improve on it, else the new one."""
|
|
67
|
+
try:
|
|
68
|
+
prev = Severity(str(baseline.get("verdict", "ok")))
|
|
69
|
+
except ValueError:
|
|
70
|
+
return report.worst
|
|
71
|
+
return prev if prev.rank > report.worst.rank else report.worst
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Check registry. Add a new check by importing it and appending an instance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datasets_lint.checks.base import Check, Context
|
|
6
|
+
from datasets_lint.checks.dup import DupCheck
|
|
7
|
+
from datasets_lint.checks.pii import PiiCheck
|
|
8
|
+
from datasets_lint.checks.stats import StatsCheck
|
|
9
|
+
|
|
10
|
+
ALL_CHECKS: list[Check] = [
|
|
11
|
+
StatsCheck(),
|
|
12
|
+
DupCheck(),
|
|
13
|
+
PiiCheck(),
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
CHECKS_BY_ID: dict[str, Check] = {c.id: c for c in ALL_CHECKS}
|
|
17
|
+
|
|
18
|
+
__all__ = ["ALL_CHECKS", "CHECKS_BY_ID", "Check", "Context"]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Check protocol and the context every check receives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from datasets_lint.config import Config
|
|
10
|
+
from datasets_lint.dataset import Dataset
|
|
11
|
+
from datasets_lint.result import CheckResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Context:
|
|
16
|
+
config: Config
|
|
17
|
+
seed: int = 0
|
|
18
|
+
|
|
19
|
+
def threshold(self, check_id: str, key: str, default: Any) -> Any:
|
|
20
|
+
return self.config.threshold(check_id, key, default)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Check(ABC):
|
|
24
|
+
"""A single data-quality check.
|
|
25
|
+
|
|
26
|
+
Subclass, set ``id``/``title``/``description``, implement ``run`` and add an
|
|
27
|
+
instance to ``datasets_lint.checks.ALL_CHECKS``. That is the whole plugin API.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
id: str = ""
|
|
31
|
+
title: str = ""
|
|
32
|
+
description: str = ""
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def run(self, ds: Dataset, ctx: Context) -> CheckResult: ...
|
|
36
|
+
|
|
37
|
+
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
38
|
+
return f"<Check {self.id}>"
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Duplicate detection: exact hashing plus MinHash/LSH near-duplicates.
|
|
2
|
+
|
|
3
|
+
Design note: MinHash *estimates* Jaccard, and on short rows (instruction-style
|
|
4
|
+
data) a 64-permutation sketch has enough variance to miss an obvious duplicate.
|
|
5
|
+
So LSH is used only to generate candidate pairs (with a deliberately loose
|
|
6
|
+
threshold) and every candidate is then verified with an exact Jaccard on the
|
|
7
|
+
shingle sets. Result: no false negatives from sketch noise, no O(n^2) scan.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import sys
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from datasketch import MinHash, MinHashLSH
|
|
18
|
+
|
|
19
|
+
from datasets_lint.checks.base import Check, Context
|
|
20
|
+
from datasets_lint.dataset import Dataset, normalize
|
|
21
|
+
from datasets_lint.result import CheckResult, Finding, Severity
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _UnionFind:
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
self.parent: dict[int, int] = {}
|
|
27
|
+
|
|
28
|
+
def find(self, x: int) -> int:
|
|
29
|
+
self.parent.setdefault(x, x)
|
|
30
|
+
root = x
|
|
31
|
+
while self.parent[root] != root:
|
|
32
|
+
root = self.parent[root]
|
|
33
|
+
while self.parent[x] != root:
|
|
34
|
+
self.parent[x], x = root, self.parent[x]
|
|
35
|
+
return root
|
|
36
|
+
|
|
37
|
+
def union(self, a: int, b: int) -> None:
|
|
38
|
+
ra, rb = self.find(a), self.find(b)
|
|
39
|
+
if ra != rb:
|
|
40
|
+
self.parent[rb] = ra
|
|
41
|
+
|
|
42
|
+
def clusters(self) -> dict[int, list[int]]:
|
|
43
|
+
groups: dict[int, list[int]] = defaultdict(list)
|
|
44
|
+
for x in self.parent:
|
|
45
|
+
groups[self.find(x)].append(x)
|
|
46
|
+
return groups
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _shingles(text: str, size: int) -> frozenset[str]:
|
|
50
|
+
words = text.split()
|
|
51
|
+
if not words:
|
|
52
|
+
return frozenset()
|
|
53
|
+
if len(words) <= size:
|
|
54
|
+
return frozenset({" ".join(words)})
|
|
55
|
+
# interning keeps memory flat: corpora repeat the same shingles constantly
|
|
56
|
+
return frozenset(
|
|
57
|
+
sys.intern(" ".join(words[i : i + size])) for i in range(len(words) - size + 1)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class DupCheck(Check):
|
|
62
|
+
id = "dup"
|
|
63
|
+
title = "Duplicates"
|
|
64
|
+
description = "Exact and near-duplicate rows (MinHash/LSH), plus cross-split overlap."
|
|
65
|
+
|
|
66
|
+
def run(self, ds: Dataset, ctx: Context) -> CheckResult:
|
|
67
|
+
threshold = float(ctx.threshold(self.id, "threshold", 0.7))
|
|
68
|
+
num_perm = int(ctx.threshold(self.id, "num_perm", 64))
|
|
69
|
+
shingle_size = int(ctx.threshold(self.id, "shingle_size", 3))
|
|
70
|
+
max_chars = int(ctx.threshold(self.id, "max_chars", 300))
|
|
71
|
+
max_near_dup_pct = float(ctx.threshold(self.id, "max_near_dup_pct", 2.0))
|
|
72
|
+
|
|
73
|
+
total = ds.audited_rows
|
|
74
|
+
metrics: dict[str, Any] = {
|
|
75
|
+
"threshold": threshold,
|
|
76
|
+
"shingle_size": shingle_size,
|
|
77
|
+
"max_chars": max_chars,
|
|
78
|
+
}
|
|
79
|
+
if total == 0:
|
|
80
|
+
return CheckResult(
|
|
81
|
+
check_id=self.id,
|
|
82
|
+
title=self.title,
|
|
83
|
+
severity=Severity.OK,
|
|
84
|
+
summary="no rows to check",
|
|
85
|
+
metrics=metrics,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Long documents are truncated: the opening characters already carry enough
|
|
89
|
+
# signal, and it keeps the sketch and the exact sets small.
|
|
90
|
+
texts = [(i, normalize(t)[:max_chars]) for i, t in ds.texts()]
|
|
91
|
+
|
|
92
|
+
exact: dict[str, list[int]] = defaultdict(list)
|
|
93
|
+
for i, norm in texts:
|
|
94
|
+
if norm:
|
|
95
|
+
exact[hashlib.sha1(norm.encode()).hexdigest()].append(i)
|
|
96
|
+
exact_groups = {k: v for k, v in exact.items() if len(v) > 1}
|
|
97
|
+
exact_rows = sum(len(v) for v in exact_groups.values())
|
|
98
|
+
metrics["exact_dup_rows"] = exact_rows
|
|
99
|
+
metrics["exact_dup_pct"] = round(100.0 * exact_rows / total, 3)
|
|
100
|
+
metrics["exact_dup_groups"] = len(exact_groups)
|
|
101
|
+
|
|
102
|
+
sets: dict[int, frozenset[str]] = {}
|
|
103
|
+
sketches: dict[int, MinHash] = {}
|
|
104
|
+
lsh = MinHashLSH(threshold=max(0.05, threshold - 0.15), num_perm=num_perm)
|
|
105
|
+
for i, norm in texts:
|
|
106
|
+
grams = _shingles(norm, shingle_size)
|
|
107
|
+
if not grams:
|
|
108
|
+
continue
|
|
109
|
+
sets[i] = grams
|
|
110
|
+
mh = MinHash(num_perm=num_perm)
|
|
111
|
+
for sh in grams:
|
|
112
|
+
mh.update(sh.encode())
|
|
113
|
+
sketches[i] = mh
|
|
114
|
+
lsh.insert(str(i), mh)
|
|
115
|
+
|
|
116
|
+
uf = _UnionFind()
|
|
117
|
+
candidate_pairs = 0
|
|
118
|
+
verified_pairs = 0
|
|
119
|
+
for i, grams in sets.items():
|
|
120
|
+
for other in lsh.query(sketches[i]):
|
|
121
|
+
j = int(other)
|
|
122
|
+
if j <= i:
|
|
123
|
+
continue
|
|
124
|
+
candidate_pairs += 1
|
|
125
|
+
other_grams = sets[j]
|
|
126
|
+
inter = len(grams & other_grams)
|
|
127
|
+
if inter == 0:
|
|
128
|
+
continue
|
|
129
|
+
jaccard = inter / len(grams | other_grams)
|
|
130
|
+
if jaccard >= threshold:
|
|
131
|
+
verified_pairs += 1
|
|
132
|
+
uf.union(i, j)
|
|
133
|
+
|
|
134
|
+
clusters = {k: v for k, v in uf.clusters().items() if len(v) > 1}
|
|
135
|
+
near_rows = sum(len(v) for v in clusters.values())
|
|
136
|
+
metrics["near_dup_rows"] = near_rows
|
|
137
|
+
metrics["near_dup_pct"] = round(100.0 * near_rows / total, 3)
|
|
138
|
+
metrics["near_dup_clusters"] = len(clusters)
|
|
139
|
+
metrics["candidate_pairs"] = candidate_pairs
|
|
140
|
+
metrics["verified_pairs"] = verified_pairs
|
|
141
|
+
if clusters:
|
|
142
|
+
metrics["largest_cluster"] = max(len(v) for v in clusters.values())
|
|
143
|
+
|
|
144
|
+
cross_source_rows = 0
|
|
145
|
+
if len(ds.sources) > 1:
|
|
146
|
+
for members in clusters.values():
|
|
147
|
+
if len({ds.source_of(ds.rows[m]) for m in members}) > 1:
|
|
148
|
+
cross_source_rows += len(members)
|
|
149
|
+
metrics["cross_source_dup_rows"] = cross_source_rows
|
|
150
|
+
metrics["cross_source_dup_pct"] = round(100.0 * cross_source_rows / total, 3)
|
|
151
|
+
|
|
152
|
+
findings: list[Finding] = []
|
|
153
|
+
for members in sorted(clusters.values(), key=len, reverse=True)[:25]:
|
|
154
|
+
members = sorted(members)
|
|
155
|
+
srcs = sorted({ds.source_of(ds.rows[m]) for m in members})
|
|
156
|
+
sample = normalize(ds.text(ds.rows[members[0]]))[:120]
|
|
157
|
+
findings.append(
|
|
158
|
+
Finding(
|
|
159
|
+
message=f"{len(members)} near-duplicate rows"
|
|
160
|
+
+ (f" across {', '.join(srcs)}" if len(srcs) > 1 else "")
|
|
161
|
+
+ f': "{sample}…"',
|
|
162
|
+
row=members[0],
|
|
163
|
+
detail={"size": len(members), "sources": srcs, "sample_rows": members[:5]},
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
severity = Severity.OK
|
|
168
|
+
if metrics["near_dup_pct"] > max_near_dup_pct:
|
|
169
|
+
severity = Severity.ERROR
|
|
170
|
+
elif metrics["near_dup_pct"] > max_near_dup_pct / 2:
|
|
171
|
+
severity = Severity.WARN
|
|
172
|
+
|
|
173
|
+
summary = (
|
|
174
|
+
f"{metrics['near_dup_pct']:.2f}% near-duplicate "
|
|
175
|
+
f"({len(clusters):,} clusters), {metrics['exact_dup_pct']:.2f}% exact"
|
|
176
|
+
)
|
|
177
|
+
if "cross_source_dup_pct" in metrics:
|
|
178
|
+
summary += f" · {metrics['cross_source_dup_pct']:.2f}% cross-file"
|
|
179
|
+
|
|
180
|
+
return CheckResult(
|
|
181
|
+
check_id=self.id,
|
|
182
|
+
title=self.title,
|
|
183
|
+
severity=severity,
|
|
184
|
+
summary=summary,
|
|
185
|
+
metrics=metrics,
|
|
186
|
+
findings=findings,
|
|
187
|
+
)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""PII detection: high-confidence secrets and warn-level contact data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from datasets_lint.checks.base import Check, Context
|
|
9
|
+
from datasets_lint.dataset import Dataset
|
|
10
|
+
from datasets_lint.result import CheckResult, Finding, Severity
|
|
11
|
+
|
|
12
|
+
# (kind, severity, pattern). Patterns are intentionally conservative: a false
|
|
13
|
+
# positive on a phone number is annoying, a false negative on an API key is an
|
|
14
|
+
# incident. High-confidence secrets default to `error`.
|
|
15
|
+
PATTERNS: list[tuple[str, Severity, re.Pattern[str]]] = [
|
|
16
|
+
(
|
|
17
|
+
"api_key",
|
|
18
|
+
Severity.ERROR,
|
|
19
|
+
re.compile(
|
|
20
|
+
r"\b(?:sk-[A-Za-z0-9]{16,}|sk-ant-[A-Za-z0-9_\-]{16,}|"
|
|
21
|
+
r"AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{36}|gho_[A-Za-z0-9]{36}|"
|
|
22
|
+
r"xox[baprs]-[A-Za-z0-9\-]{10,}|AIza[0-9A-Za-z_\-]{35})\b"
|
|
23
|
+
),
|
|
24
|
+
),
|
|
25
|
+
(
|
|
26
|
+
"private_key",
|
|
27
|
+
Severity.ERROR,
|
|
28
|
+
re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
|
|
29
|
+
),
|
|
30
|
+
("iban", Severity.ERROR, re.compile(r"\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b")),
|
|
31
|
+
("ssn", Severity.ERROR, re.compile(r"(?<!\d)\d{3}-\d{2}-\d{4}(?!\d)")),
|
|
32
|
+
(
|
|
33
|
+
"email",
|
|
34
|
+
Severity.WARN,
|
|
35
|
+
re.compile(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b"),
|
|
36
|
+
),
|
|
37
|
+
(
|
|
38
|
+
"phone",
|
|
39
|
+
Severity.WARN,
|
|
40
|
+
re.compile(
|
|
41
|
+
r"(?<![\d.])(?:\+\d{1,3}[\s.\-]?)?(?:\(\d{2,4}\)[\s.\-]?)?\d{3,4}[\s.\-]\d{3,4}(?![\d.])"
|
|
42
|
+
),
|
|
43
|
+
),
|
|
44
|
+
(
|
|
45
|
+
"ipv4",
|
|
46
|
+
Severity.WARN,
|
|
47
|
+
re.compile(r"\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b"),
|
|
48
|
+
),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
# Common false positives for the phone pattern (dates, versions, ranges).
|
|
52
|
+
_PHONE_FALSE_POSITIVES = re.compile(r"^(?:19|20)\d{2}[\s.\-]\d{1,2}[\s.\-]\d{1,2}$|^\d+\.\d+\.\d+$")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def redact(value: str) -> str:
|
|
56
|
+
"""Keep the shape, hide the payload: `a***@example.com`, `sk-***`, ..."""
|
|
57
|
+
if len(value) <= 4:
|
|
58
|
+
return "*" * len(value)
|
|
59
|
+
if "@" in value:
|
|
60
|
+
local, _, domain = value.partition("@")
|
|
61
|
+
return f"{local[:1]}***@{domain}"
|
|
62
|
+
if re.fullmatch(r"[A-Za-z0-9_\-]+", value) and len(value) > 12:
|
|
63
|
+
return f"{value[:4]}***"
|
|
64
|
+
return f"{value[:2]}***{value[-2:]}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class PiiCheck(Check):
|
|
68
|
+
id = "pii"
|
|
69
|
+
title = "PII & secrets"
|
|
70
|
+
description = "Emails, phones, IPs, IBANs, SSNs and API keys. Findings are redacted."
|
|
71
|
+
|
|
72
|
+
def run(self, ds: Dataset, ctx: Context) -> CheckResult:
|
|
73
|
+
max_error_pct = float(ctx.threshold(self.id, "max_error_findings_pct", 0.1))
|
|
74
|
+
max_warn_pct = float(ctx.threshold(self.id, "max_warn_findings_pct", 5.0))
|
|
75
|
+
|
|
76
|
+
counts: dict[str, int] = {}
|
|
77
|
+
by_severity: dict[str, int] = {"warn": 0, "error": 0}
|
|
78
|
+
findings: list[Finding] = []
|
|
79
|
+
affected_rows = 0
|
|
80
|
+
|
|
81
|
+
for i, text in ds.texts():
|
|
82
|
+
if not text:
|
|
83
|
+
continue
|
|
84
|
+
row_hit = False
|
|
85
|
+
for kind, severity, pattern in PATTERNS:
|
|
86
|
+
for match in pattern.finditer(text):
|
|
87
|
+
value = match.group(0)
|
|
88
|
+
if kind == "phone" and _PHONE_FALSE_POSITIVES.match(value.strip()):
|
|
89
|
+
continue
|
|
90
|
+
if kind == "iban" and not re.search(r"\d", value):
|
|
91
|
+
continue
|
|
92
|
+
row_hit = True
|
|
93
|
+
counts[kind] = counts.get(kind, 0) + 1
|
|
94
|
+
by_severity[severity.value] += 1
|
|
95
|
+
if len(findings) < 50:
|
|
96
|
+
findings.append(
|
|
97
|
+
Finding(
|
|
98
|
+
message=f"{kind}: {redact(value)}",
|
|
99
|
+
row=i,
|
|
100
|
+
detail={"kind": kind, "severity": severity.value},
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
if row_hit:
|
|
104
|
+
affected_rows += 1
|
|
105
|
+
|
|
106
|
+
total = max(ds.audited_rows, 1)
|
|
107
|
+
affected_pct = round(100.0 * affected_rows / total, 3)
|
|
108
|
+
metrics: dict[str, Any] = {
|
|
109
|
+
"rows_with_pii": affected_rows,
|
|
110
|
+
"rows_with_pii_pct": affected_pct,
|
|
111
|
+
"findings_by_kind": counts,
|
|
112
|
+
"findings_by_severity": by_severity,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
severity = Severity.OK
|
|
116
|
+
if by_severity["error"] > 0 and affected_pct > max_error_pct:
|
|
117
|
+
severity = Severity.ERROR
|
|
118
|
+
elif affected_pct > max_warn_pct or by_severity["error"] > 0:
|
|
119
|
+
severity = Severity.WARN
|
|
120
|
+
|
|
121
|
+
if counts:
|
|
122
|
+
parts = ", ".join(f"{k} {v}" for k, v in sorted(counts.items(), key=lambda kv: -kv[1]))
|
|
123
|
+
summary = f"{affected_pct:.2f}% of rows contain PII ({parts})"
|
|
124
|
+
else:
|
|
125
|
+
summary = "no PII patterns found"
|
|
126
|
+
|
|
127
|
+
return CheckResult(
|
|
128
|
+
check_id=self.id,
|
|
129
|
+
title=self.title,
|
|
130
|
+
severity=severity,
|
|
131
|
+
summary=summary,
|
|
132
|
+
metrics=metrics,
|
|
133
|
+
findings=findings,
|
|
134
|
+
)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Distribution sanity: empties, nulls, length outliers, label balance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from datasets_lint.checks.base import Check, Context
|
|
9
|
+
from datasets_lint.dataset import Dataset, normalize
|
|
10
|
+
from datasets_lint.result import CheckResult, Finding, Severity
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StatsCheck(Check):
|
|
14
|
+
id = "stats"
|
|
15
|
+
title = "Distribution & shape"
|
|
16
|
+
description = "Row counts, nulls, empty texts, length percentiles, label balance."
|
|
17
|
+
|
|
18
|
+
def run(self, ds: Dataset, ctx: Context) -> CheckResult:
|
|
19
|
+
findings: list[Finding] = []
|
|
20
|
+
metrics: dict[str, Any] = {"rows": ds.audited_rows, "columns": len(ds.columns)}
|
|
21
|
+
|
|
22
|
+
null_pct: dict[str, float] = {}
|
|
23
|
+
for col in ds.columns:
|
|
24
|
+
nulls = sum(1 for r in ds.rows if r.get(col) is None)
|
|
25
|
+
null_pct[col] = round(100.0 * nulls / max(ds.audited_rows, 1), 3)
|
|
26
|
+
metrics["null_pct"] = null_pct
|
|
27
|
+
|
|
28
|
+
empty_rows = 0
|
|
29
|
+
lengths: list[int] = []
|
|
30
|
+
for i, text in ds.texts():
|
|
31
|
+
norm = normalize(text)
|
|
32
|
+
if not norm:
|
|
33
|
+
empty_rows += 1
|
|
34
|
+
findings.append(Finding(message="empty or whitespace-only text", row=i))
|
|
35
|
+
lengths.append(len(norm))
|
|
36
|
+
|
|
37
|
+
empty_pct = round(100.0 * empty_rows / max(ds.audited_rows, 1), 3)
|
|
38
|
+
metrics["empty_rows"] = empty_rows
|
|
39
|
+
metrics["empty_pct"] = empty_pct
|
|
40
|
+
metrics["text_column"] = ds.text_column
|
|
41
|
+
|
|
42
|
+
if lengths:
|
|
43
|
+
s = sorted(lengths)
|
|
44
|
+
n = len(s)
|
|
45
|
+
metrics["text_len"] = {
|
|
46
|
+
"p50": s[n // 2],
|
|
47
|
+
"p95": s[min(n - 1, int(n * 0.95))],
|
|
48
|
+
"max": s[-1],
|
|
49
|
+
"mean": round(sum(s) / n, 1),
|
|
50
|
+
}
|
|
51
|
+
metrics["n_unique_texts"] = len({normalize(t) for _, t in ds.texts()})
|
|
52
|
+
|
|
53
|
+
if ds.label_column:
|
|
54
|
+
counts = Counter(
|
|
55
|
+
str(r.get(ds.label_column)) for r in ds.rows if r.get(ds.label_column) is not None
|
|
56
|
+
)
|
|
57
|
+
total = sum(counts.values()) or 1
|
|
58
|
+
balance = {k: round(100.0 * v / total, 2) for k, v in counts.most_common(20)}
|
|
59
|
+
metrics["label_column"] = ds.label_column
|
|
60
|
+
metrics["label_balance_pct"] = balance
|
|
61
|
+
if len(balance) > 1:
|
|
62
|
+
smallest = min(balance.values())
|
|
63
|
+
if smallest < ctx.threshold(self.id, "min_label_pct", 1.0):
|
|
64
|
+
findings.append(
|
|
65
|
+
Finding(
|
|
66
|
+
message=f"rare label: {smallest:.2f}% of rows "
|
|
67
|
+
f"(threshold {ctx.threshold(self.id, 'min_label_pct', 1.0)}%)",
|
|
68
|
+
detail={"label_balance_pct": balance},
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
max_empty = float(ctx.threshold(self.id, "max_empty_pct", 1.0))
|
|
73
|
+
max_null = float(ctx.threshold(self.id, "max_null_pct", 50.0))
|
|
74
|
+
|
|
75
|
+
severity = Severity.OK
|
|
76
|
+
if empty_pct > max_empty:
|
|
77
|
+
severity = Severity.ERROR
|
|
78
|
+
elif any(v > max_null for v in null_pct.values()):
|
|
79
|
+
severity = Severity.WARN
|
|
80
|
+
|
|
81
|
+
worst_null = max(null_pct.values()) if null_pct else 0.0
|
|
82
|
+
summary = (
|
|
83
|
+
f"{ds.audited_rows:,} rows · {empty_pct:.2f}% empty · "
|
|
84
|
+
f"worst column {worst_null:.1f}% null"
|
|
85
|
+
)
|
|
86
|
+
if "text_len" in metrics:
|
|
87
|
+
summary += f" · median {metrics['text_len']['p50']:,} chars"
|
|
88
|
+
|
|
89
|
+
return CheckResult(
|
|
90
|
+
check_id=self.id,
|
|
91
|
+
title=self.title,
|
|
92
|
+
severity=severity,
|
|
93
|
+
summary=summary,
|
|
94
|
+
metrics=metrics,
|
|
95
|
+
findings=findings[:50],
|
|
96
|
+
)
|