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.
@@ -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
+ )