datasentry-core 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.
- datasentry_core/__init__.py +111 -0
- datasentry_core/connectors/__init__.py +49 -0
- datasentry_core/connectors/base.py +108 -0
- datasentry_core/connectors/csv.py +312 -0
- datasentry_core/connectors/duckdb.py +105 -0
- datasentry_core/connectors/errors.py +19 -0
- datasentry_core/connectors/file_based.py +209 -0
- datasentry_core/connectors/jsonl.py +71 -0
- datasentry_core/connectors/parquet.py +64 -0
- datasentry_core/connectors/registry.py +55 -0
- datasentry_core/connectors/spec.py +37 -0
- datasentry_core/connectors/xlsx.py +109 -0
- datasentry_core/contracts/__init__.py +24 -0
- datasentry_core/contracts/exporters.py +330 -0
- datasentry_core/detectors/__init__.py +15 -0
- datasentry_core/detectors/base.py +120 -0
- datasentry_core/detectors/common.py +156 -0
- datasentry_core/detectors/cross_field.py +157 -0
- datasentry_core/detectors/cross_table.py +220 -0
- datasentry_core/detectors/datetime.py +343 -0
- datasentry_core/detectors/initial/__init__.py +117 -0
- datasentry_core/detectors/initial/anomaly_ml.py +204 -0
- datasentry_core/detectors/initial/categorical.py +252 -0
- datasentry_core/detectors/initial/formula.py +66 -0
- datasentry_core/detectors/initial/missing.py +150 -0
- datasentry_core/detectors/initial/numeric.py +302 -0
- datasentry_core/detectors/initial/textual.py +407 -0
- datasentry_core/detectors/initial/uniqueness.py +183 -0
- datasentry_core/detectors/missingness.py +424 -0
- datasentry_core/detectors/runner.py +228 -0
- datasentry_core/detectors/safe_eval.py +255 -0
- datasentry_core/detectors/textual_variants.py +300 -0
- datasentry_core/drift/__init__.py +8 -0
- datasentry_core/drift/engine.py +182 -0
- datasentry_core/engine/__init__.py +16 -0
- datasentry_core/engine/base.py +34 -0
- datasentry_core/engine/duckdb.py +79 -0
- datasentry_core/engine/fusion.py +193 -0
- datasentry_core/engine/profiler.py +165 -0
- datasentry_core/engine/sql_guard.py +23 -0
- datasentry_core/llm/provider.py +66 -0
- datasentry_core/models/__init__.py +118 -0
- datasentry_core/models/audit.py +46 -0
- datasentry_core/models/contract.py +89 -0
- datasentry_core/models/detector.py +66 -0
- datasentry_core/models/drift.py +47 -0
- datasentry_core/models/enums.py +126 -0
- datasentry_core/models/evidence.py +39 -0
- datasentry_core/models/feedback.py +30 -0
- datasentry_core/models/fingerprint.py +24 -0
- datasentry_core/models/issue.py +49 -0
- datasentry_core/models/llm.py +69 -0
- datasentry_core/models/profile.py +79 -0
- datasentry_core/models/quality.py +16 -0
- datasentry_core/models/repair.py +94 -0
- datasentry_core/models/rules.py +82 -0
- datasentry_core/models/scan.py +112 -0
- datasentry_core/models/validation.py +22 -0
- datasentry_core/plugins.py +91 -0
- datasentry_core/privacy/redactor.py +155 -0
- datasentry_core/repair/__init__.py +5 -0
- datasentry_core/repair/engine.py +455 -0
- datasentry_core/reporting/__init__.py +100 -0
- datasentry_core/reporting/html.py +230 -0
- datasentry_core/reporting/junit.py +80 -0
- datasentry_core/reporting/markdown.py +83 -0
- datasentry_core/reporting/sarif.py +92 -0
- datasentry_core/rules/engine.py +133 -0
- datasentry_core/scoring/__init__.py +30 -0
- datasentry_core/scoring/engine.py +138 -0
- datasentry_core/scoring/gate.py +96 -0
- datasentry_core/scoring/quality.py +137 -0
- datasentry_core/scoring/weights.py +33 -0
- datasentry_core/storage/__init__.py +15 -0
- datasentry_core/storage/paths.py +47 -0
- datasentry_core/storage/schema.py +343 -0
- datasentry_core/storage/store.py +675 -0
- datasentry_core-0.1.0.dist-info/METADATA +59 -0
- datasentry_core-0.1.0.dist-info/RECORD +80 -0
- datasentry_core-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""日期时间检测器(11.8,C-13 P0 化 6 个核心):invalid/impossible/future/stale/mixed/duplicate。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from typing import ClassVar
|
|
8
|
+
|
|
9
|
+
from datasentry_core.detectors.base import DetectionContext
|
|
10
|
+
from datasentry_core.detectors.common import (
|
|
11
|
+
DetectorBase,
|
|
12
|
+
datetime_columns,
|
|
13
|
+
make_candidate,
|
|
14
|
+
make_evidence,
|
|
15
|
+
quote_ident,
|
|
16
|
+
quote_re,
|
|
17
|
+
string_columns,
|
|
18
|
+
)
|
|
19
|
+
from datasentry_core.models.detector import DetectorCapabilities, IssueCandidate
|
|
20
|
+
from datasentry_core.models.enums import EvidenceType, QualityDimension, Severity
|
|
21
|
+
|
|
22
|
+
# 字符串日期列判定(列名特征;VARCHAR 列才适用 invalid/impossible/mixed)
|
|
23
|
+
# 注意:时间戳列(created_at 等,物理 TIMESTAMP 或含时间值)不归此组,
|
|
24
|
+
# 由 future/stale/duplicate 经 datetime_columns() 处理
|
|
25
|
+
_DATE_HINTS = ("date", "dob", "birth")
|
|
26
|
+
|
|
27
|
+
# stale_date 豁免列(历史数据字段,规格 11.8:由语义类型豁免;MVP 按列名特征)
|
|
28
|
+
_STALE_EXEMPT_HINTS = ("birth", "dob", "founded", "hire", "established", "historical", "history")
|
|
29
|
+
|
|
30
|
+
_ISO_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
31
|
+
_SLASH_RE = re.compile(r"^\d{1,2}/\d{1,2}/\d{4}$")
|
|
32
|
+
_DOT_RE = re.compile(r"^\d{1,2}\.\d{1,2}\.\d{4}$")
|
|
33
|
+
_COMPACT_RE = re.compile(r"^\d{8}$")
|
|
34
|
+
|
|
35
|
+
_DATETIME_RE = r"^\d{4}-\d{2}-\d{2}$"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _date_hinted_string_columns(context: DetectionContext) -> list[str]:
|
|
39
|
+
return [col for col in string_columns(context) if any(h in col.lower() for h in _DATE_HINTS)]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class InvalidDateDetector(DetectorBase):
|
|
43
|
+
"""无效日期(11.8):字符串日期列中不匹配 ISO 日期模式的值。"""
|
|
44
|
+
|
|
45
|
+
detector_id = "invalid_date"
|
|
46
|
+
display_name = "Invalid Date"
|
|
47
|
+
description = "Reports non-null string values that do not match a date pattern."
|
|
48
|
+
quality_dimension = QualityDimension.VALIDITY
|
|
49
|
+
capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(supports_sql_pushdown=True)
|
|
50
|
+
|
|
51
|
+
def supports(self, context: DetectionContext) -> bool:
|
|
52
|
+
return bool(_date_hinted_string_columns(context))
|
|
53
|
+
|
|
54
|
+
def detect(self, context: DetectionContext) -> list[IssueCandidate]:
|
|
55
|
+
candidates: list[IssueCandidate] = []
|
|
56
|
+
for col in _date_hinted_string_columns(context):
|
|
57
|
+
q = quote_ident(col)
|
|
58
|
+
table = context.handle.sql_aggregate(
|
|
59
|
+
f"SELECT count(*) AS n FROM data "
|
|
60
|
+
f"WHERE {q} IS NOT NULL AND NOT regexp_matches(trim({q}), {quote_re(_DATETIME_RE)})"
|
|
61
|
+
).table
|
|
62
|
+
count = int(table.column("n").to_pylist()[0])
|
|
63
|
+
if count > 0:
|
|
64
|
+
candidates.append(
|
|
65
|
+
make_candidate(
|
|
66
|
+
detector_id=self.detector_id,
|
|
67
|
+
detector_version=self.detector_version,
|
|
68
|
+
context=context,
|
|
69
|
+
issue_type="invalid_date",
|
|
70
|
+
columns=[col],
|
|
71
|
+
affected_count=count,
|
|
72
|
+
evidence=[
|
|
73
|
+
make_evidence(
|
|
74
|
+
detector_id=self.detector_id,
|
|
75
|
+
detector_version=self.detector_version,
|
|
76
|
+
evidence_type=EvidenceType.PATTERN_MATCH,
|
|
77
|
+
description=f"{count} values fail ISO date pattern",
|
|
78
|
+
data={"count": count, "pattern": _DATETIME_RE},
|
|
79
|
+
)
|
|
80
|
+
],
|
|
81
|
+
raw_score=count,
|
|
82
|
+
confidence=0.9,
|
|
83
|
+
severity=Severity.MEDIUM,
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
return candidates
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ImpossibleDateDetector(DetectorBase):
|
|
90
|
+
"""不可能日期(11.8):格式合法但日历不存在(如 2024-02-30)。"""
|
|
91
|
+
|
|
92
|
+
detector_id = "impossible_date"
|
|
93
|
+
display_name = "Impossible Date"
|
|
94
|
+
description = "Reports calendar-invalid dates (e.g. 2024-02-30)."
|
|
95
|
+
quality_dimension = QualityDimension.VALIDITY
|
|
96
|
+
capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(supports_sql_pushdown=True)
|
|
97
|
+
|
|
98
|
+
def supports(self, context: DetectionContext) -> bool:
|
|
99
|
+
return bool(_date_hinted_string_columns(context))
|
|
100
|
+
|
|
101
|
+
def detect(self, context: DetectionContext) -> list[IssueCandidate]:
|
|
102
|
+
candidates: list[IssueCandidate] = []
|
|
103
|
+
for col in _date_hinted_string_columns(context):
|
|
104
|
+
q = quote_ident(col)
|
|
105
|
+
table = context.handle.sql_aggregate(
|
|
106
|
+
f"SELECT count(*) AS n FROM data "
|
|
107
|
+
f"WHERE {q} IS NOT NULL "
|
|
108
|
+
f"AND regexp_matches(trim({q}), {quote_re(_DATETIME_RE)}) "
|
|
109
|
+
f"AND try_strptime(trim({q}), '%Y-%m-%d') IS NULL"
|
|
110
|
+
).table
|
|
111
|
+
count = int(table.column("n").to_pylist()[0])
|
|
112
|
+
if count > 0:
|
|
113
|
+
candidates.append(
|
|
114
|
+
make_candidate(
|
|
115
|
+
detector_id=self.detector_id,
|
|
116
|
+
detector_version=self.detector_version,
|
|
117
|
+
context=context,
|
|
118
|
+
issue_type="impossible_date",
|
|
119
|
+
columns=[col],
|
|
120
|
+
affected_count=count,
|
|
121
|
+
evidence=[
|
|
122
|
+
make_evidence(
|
|
123
|
+
detector_id=self.detector_id,
|
|
124
|
+
detector_version=self.detector_version,
|
|
125
|
+
evidence_type=EvidenceType.PATTERN_MATCH,
|
|
126
|
+
description=f"{count} calendar-invalid dates",
|
|
127
|
+
data={"count": count, "pattern": _DATETIME_RE},
|
|
128
|
+
)
|
|
129
|
+
],
|
|
130
|
+
raw_score=count,
|
|
131
|
+
confidence=0.95,
|
|
132
|
+
severity=Severity.HIGH,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
return candidates
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class FutureDateDetector(DetectorBase):
|
|
139
|
+
"""未来日期(11.8):date > now + 1 day(排除时区漂移)。"""
|
|
140
|
+
|
|
141
|
+
detector_id = "future_date"
|
|
142
|
+
display_name = "Future Date"
|
|
143
|
+
description = "Reports dates more than 1 day in the future."
|
|
144
|
+
quality_dimension = QualityDimension.VALIDITY
|
|
145
|
+
capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(supports_sql_pushdown=True)
|
|
146
|
+
default_thresholds: ClassVar[dict[str, float | int | str]] = {"max_future_days": 1}
|
|
147
|
+
|
|
148
|
+
def detect(self, context: DetectionContext) -> list[IssueCandidate]:
|
|
149
|
+
candidates: list[IssueCandidate] = []
|
|
150
|
+
for col in datetime_columns(context):
|
|
151
|
+
q = quote_ident(col)
|
|
152
|
+
table = context.handle.sql_aggregate(
|
|
153
|
+
f"SELECT count(*) AS n FROM data "
|
|
154
|
+
f"WHERE {q} IS NOT NULL AND {q} > current_date + INTERVAL 1 DAY"
|
|
155
|
+
).table
|
|
156
|
+
count = int(table.column("n").to_pylist()[0])
|
|
157
|
+
if count > 0:
|
|
158
|
+
candidates.append(
|
|
159
|
+
make_candidate(
|
|
160
|
+
detector_id=self.detector_id,
|
|
161
|
+
detector_version=self.detector_version,
|
|
162
|
+
context=context,
|
|
163
|
+
issue_type="future_date",
|
|
164
|
+
columns=[col],
|
|
165
|
+
affected_count=count,
|
|
166
|
+
evidence=[
|
|
167
|
+
make_evidence(
|
|
168
|
+
detector_id=self.detector_id,
|
|
169
|
+
detector_version=self.detector_version,
|
|
170
|
+
evidence_type=EvidenceType.CONSTRAINT_VIOLATION,
|
|
171
|
+
description=f"{count} dates beyond today + 1 day",
|
|
172
|
+
data={"count": count, "max_future_days": 1},
|
|
173
|
+
)
|
|
174
|
+
],
|
|
175
|
+
raw_score=count,
|
|
176
|
+
confidence=0.9,
|
|
177
|
+
severity=Severity.LOW,
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
return candidates
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class StaleDateDetector(DetectorBase):
|
|
184
|
+
"""过期日期(11.8):date < now - 365d;历史数据字段按列名豁免。"""
|
|
185
|
+
|
|
186
|
+
detector_id = "stale_date"
|
|
187
|
+
display_name = "Stale Date"
|
|
188
|
+
description = "Reports dates older than 365 days (exempting historical fields)."
|
|
189
|
+
quality_dimension = QualityDimension.VALIDITY
|
|
190
|
+
capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(supports_sql_pushdown=True)
|
|
191
|
+
default_thresholds: ClassVar[dict[str, float | int | str]] = {"max_age_days": 365}
|
|
192
|
+
|
|
193
|
+
def detect(self, context: DetectionContext) -> list[IssueCandidate]:
|
|
194
|
+
candidates: list[IssueCandidate] = []
|
|
195
|
+
for col in datetime_columns(context):
|
|
196
|
+
if any(h in col.lower() for h in _STALE_EXEMPT_HINTS):
|
|
197
|
+
continue
|
|
198
|
+
q = quote_ident(col)
|
|
199
|
+
table = context.handle.sql_aggregate(
|
|
200
|
+
f"SELECT count(*) AS n FROM data "
|
|
201
|
+
f"WHERE {q} IS NOT NULL AND {q} < current_date - INTERVAL 365 DAY"
|
|
202
|
+
).table
|
|
203
|
+
count = int(table.column("n").to_pylist()[0])
|
|
204
|
+
if count > 0:
|
|
205
|
+
candidates.append(
|
|
206
|
+
make_candidate(
|
|
207
|
+
detector_id=self.detector_id,
|
|
208
|
+
detector_version=self.detector_version,
|
|
209
|
+
context=context,
|
|
210
|
+
issue_type="stale_date",
|
|
211
|
+
columns=[col],
|
|
212
|
+
affected_count=count,
|
|
213
|
+
evidence=[
|
|
214
|
+
make_evidence(
|
|
215
|
+
detector_id=self.detector_id,
|
|
216
|
+
detector_version=self.detector_version,
|
|
217
|
+
evidence_type=EvidenceType.CONSTRAINT_VIOLATION,
|
|
218
|
+
description=f"{count} dates older than 365 days",
|
|
219
|
+
data={"count": count, "max_age_days": 365},
|
|
220
|
+
)
|
|
221
|
+
],
|
|
222
|
+
raw_score=count,
|
|
223
|
+
confidence=0.85,
|
|
224
|
+
severity=Severity.LOW,
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
return candidates
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _classify_date(value: str) -> str:
|
|
231
|
+
v = value.strip()
|
|
232
|
+
if _ISO_RE.match(v):
|
|
233
|
+
return "iso"
|
|
234
|
+
if _SLASH_RE.match(v):
|
|
235
|
+
return "slash"
|
|
236
|
+
if _DOT_RE.match(v):
|
|
237
|
+
return "dot"
|
|
238
|
+
if _COMPACT_RE.match(v):
|
|
239
|
+
return "compact"
|
|
240
|
+
return "other"
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class MixedDateFormatDetector(DetectorBase):
|
|
244
|
+
"""混合日期格式(11.8):同一列 >=2 种解析格式且占比均 > 0.02,报告各格式占比。"""
|
|
245
|
+
|
|
246
|
+
detector_id = "mixed_date_format"
|
|
247
|
+
display_name = "Mixed Date Format"
|
|
248
|
+
description = "Reports columns with >=2 date formats each covering >2% of values."
|
|
249
|
+
quality_dimension = QualityDimension.VALIDITY
|
|
250
|
+
capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(supports_sql_pushdown=True)
|
|
251
|
+
default_thresholds: ClassVar[dict[str, float | int | str]] = {"min_ratio": 0.02}
|
|
252
|
+
|
|
253
|
+
def supports(self, context: DetectionContext) -> bool:
|
|
254
|
+
return bool(_date_hinted_string_columns(context))
|
|
255
|
+
|
|
256
|
+
def detect(self, context: DetectionContext) -> list[IssueCandidate]:
|
|
257
|
+
candidates: list[IssueCandidate] = []
|
|
258
|
+
for col in _date_hinted_string_columns(context):
|
|
259
|
+
q = quote_ident(col)
|
|
260
|
+
table = context.handle.sql_aggregate(
|
|
261
|
+
f"SELECT {q} AS v FROM data WHERE {q} IS NOT NULL LIMIT 10000"
|
|
262
|
+
).table
|
|
263
|
+
values = table.column("v").to_pylist()
|
|
264
|
+
if len(set(values)) < 10:
|
|
265
|
+
continue
|
|
266
|
+
counter: Counter[str] = Counter(_classify_date(str(v)) for v in values)
|
|
267
|
+
total = sum(counter.values())
|
|
268
|
+
formats = {fmt: count / total for fmt, count in counter.items() if fmt != "other"}
|
|
269
|
+
major = [fmt for fmt, ratio in formats.items() if ratio > 0.02]
|
|
270
|
+
if len(major) < 2:
|
|
271
|
+
continue
|
|
272
|
+
secondary = sum(counter[f] for f in formats if f not in (major[0],))
|
|
273
|
+
candidates.append(
|
|
274
|
+
make_candidate(
|
|
275
|
+
detector_id=self.detector_id,
|
|
276
|
+
detector_version=self.detector_version,
|
|
277
|
+
context=context,
|
|
278
|
+
issue_type="mixed_date_format",
|
|
279
|
+
columns=[col],
|
|
280
|
+
affected_count=secondary,
|
|
281
|
+
evidence=[
|
|
282
|
+
make_evidence(
|
|
283
|
+
detector_id=self.detector_id,
|
|
284
|
+
detector_version=self.detector_version,
|
|
285
|
+
evidence_type=EvidenceType.STATISTICAL_MEASURE,
|
|
286
|
+
description=f"{len(major)} date formats each >2%",
|
|
287
|
+
data={"formats": {k: round(v, 4) for k, v in formats.items()}},
|
|
288
|
+
)
|
|
289
|
+
],
|
|
290
|
+
raw_score=len(major),
|
|
291
|
+
confidence=0.85,
|
|
292
|
+
severity=Severity.LOW,
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
return candidates
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class DuplicateTimestampDetector(DetectorBase):
|
|
299
|
+
"""重复时间戳(11.8):同一时间戳出现 > 2x;主键联合判断归 V1(无主键声明)。"""
|
|
300
|
+
|
|
301
|
+
detector_id = "duplicate_timestamp"
|
|
302
|
+
display_name = "Duplicate Timestamp"
|
|
303
|
+
description = "Reports timestamps appearing more than twice."
|
|
304
|
+
quality_dimension = QualityDimension.VALIDITY
|
|
305
|
+
capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(supports_sql_pushdown=True)
|
|
306
|
+
default_thresholds: ClassVar[dict[str, float | int | str]] = {"max_duplicates": 2}
|
|
307
|
+
|
|
308
|
+
def detect(self, context: DetectionContext) -> list[IssueCandidate]:
|
|
309
|
+
candidates: list[IssueCandidate] = []
|
|
310
|
+
for col in datetime_columns(context):
|
|
311
|
+
q = quote_ident(col)
|
|
312
|
+
table = context.handle.sql_aggregate(
|
|
313
|
+
f"SELECT {q} AS v, count(*) AS n FROM data "
|
|
314
|
+
f"WHERE {q} IS NOT NULL GROUP BY {q} "
|
|
315
|
+
f"HAVING count(*) > 2 LIMIT 20"
|
|
316
|
+
).table
|
|
317
|
+
counts = table.column("n").to_pylist()
|
|
318
|
+
if not counts:
|
|
319
|
+
continue
|
|
320
|
+
affected = sum(int(c) - 1 for c in counts)
|
|
321
|
+
candidates.append(
|
|
322
|
+
make_candidate(
|
|
323
|
+
detector_id=self.detector_id,
|
|
324
|
+
detector_version=self.detector_version,
|
|
325
|
+
context=context,
|
|
326
|
+
issue_type="duplicate_timestamp",
|
|
327
|
+
columns=[col],
|
|
328
|
+
affected_count=affected,
|
|
329
|
+
evidence=[
|
|
330
|
+
make_evidence(
|
|
331
|
+
detector_id=self.detector_id,
|
|
332
|
+
detector_version=self.detector_version,
|
|
333
|
+
evidence_type=EvidenceType.DUPLICATE_MATCH,
|
|
334
|
+
description=f"{len(counts)} timestamps appear more than twice",
|
|
335
|
+
data={"groups": len(counts), "affected": affected},
|
|
336
|
+
)
|
|
337
|
+
],
|
|
338
|
+
raw_score=len(counts),
|
|
339
|
+
confidence=0.9,
|
|
340
|
+
severity=Severity.MEDIUM,
|
|
341
|
+
)
|
|
342
|
+
)
|
|
343
|
+
return candidates
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""首批确定性检测器(Step 6/13/14/15/16/17):36 种(11.3~11.10 的子集,MVP 全表单表)。
|
|
2
|
+
|
|
3
|
+
注册入口:register_default_detectors(registry)。
|
|
4
|
+
后续步骤补齐剩余检测器(重复 Level 1、分布漂移等)。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datasentry_core.detectors.base import Detector, DetectorRegistry
|
|
10
|
+
from datasentry_core.detectors.cross_field import CrossFieldRuleDetector
|
|
11
|
+
from datasentry_core.detectors.cross_table import ForeignKeyViolationDetector
|
|
12
|
+
from datasentry_core.detectors.datetime import (
|
|
13
|
+
DuplicateTimestampDetector,
|
|
14
|
+
FutureDateDetector,
|
|
15
|
+
ImpossibleDateDetector,
|
|
16
|
+
InvalidDateDetector,
|
|
17
|
+
MixedDateFormatDetector,
|
|
18
|
+
StaleDateDetector,
|
|
19
|
+
)
|
|
20
|
+
from datasentry_core.detectors.initial.anomaly_ml import ModelOutlierDetector
|
|
21
|
+
from datasentry_core.detectors.initial.categorical import (
|
|
22
|
+
CategoryExplosionDetector,
|
|
23
|
+
InconsistentCaseDetector,
|
|
24
|
+
RareCategoryDetector,
|
|
25
|
+
SuspiciousPlaceholderDetector,
|
|
26
|
+
)
|
|
27
|
+
from datasentry_core.detectors.initial.formula import FormulaInjectionDetector
|
|
28
|
+
from datasentry_core.detectors.initial.missing import (
|
|
29
|
+
ExcessiveNullRateDetector,
|
|
30
|
+
SuspiciousMissingTokenDetector,
|
|
31
|
+
)
|
|
32
|
+
from datasentry_core.detectors.initial.numeric import (
|
|
33
|
+
HistogramRarityDetector,
|
|
34
|
+
IqrOutlierDetector,
|
|
35
|
+
ModifiedZScoreDetector,
|
|
36
|
+
PercentileOutlierDetector,
|
|
37
|
+
TailProbabilityDetector,
|
|
38
|
+
)
|
|
39
|
+
from datasentry_core.detectors.initial.textual import (
|
|
40
|
+
HiddenControlCharacterDetector,
|
|
41
|
+
InvalidEmailDetector,
|
|
42
|
+
InvalidIpDetector,
|
|
43
|
+
InvalidPhoneDetector,
|
|
44
|
+
InvalidUrlDetector,
|
|
45
|
+
LeadingTrailingWhitespaceDetector,
|
|
46
|
+
RepeatedWhitespaceDetector,
|
|
47
|
+
UnusualLengthDetector,
|
|
48
|
+
)
|
|
49
|
+
from datasentry_core.detectors.initial.uniqueness import (
|
|
50
|
+
FuzzyDuplicateDetector,
|
|
51
|
+
UniquenessViolationDetector,
|
|
52
|
+
)
|
|
53
|
+
from datasentry_core.detectors.missingness import (
|
|
54
|
+
ConditionalMissingnessDetector,
|
|
55
|
+
CorrelatedMissingnessDetector,
|
|
56
|
+
GroupMissingnessDetector,
|
|
57
|
+
SuddenMissingnessDetector,
|
|
58
|
+
)
|
|
59
|
+
from datasentry_core.detectors.textual_variants import (
|
|
60
|
+
FullwidthCharacterDetector,
|
|
61
|
+
InvalidNumericDetector,
|
|
62
|
+
MojibakeCharacterDetector,
|
|
63
|
+
SpellingVariantDetector,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def build_initial_detectors() -> list[Detector]:
|
|
68
|
+
"""首批检测器实例列表(按执行顺序)。"""
|
|
69
|
+
return [
|
|
70
|
+
ExcessiveNullRateDetector(),
|
|
71
|
+
SuspiciousMissingTokenDetector(),
|
|
72
|
+
SuddenMissingnessDetector(),
|
|
73
|
+
GroupMissingnessDetector(),
|
|
74
|
+
ConditionalMissingnessDetector(),
|
|
75
|
+
CorrelatedMissingnessDetector(),
|
|
76
|
+
UniquenessViolationDetector(),
|
|
77
|
+
FuzzyDuplicateDetector(),
|
|
78
|
+
SuspiciousPlaceholderDetector(),
|
|
79
|
+
RareCategoryDetector(),
|
|
80
|
+
CategoryExplosionDetector(),
|
|
81
|
+
InconsistentCaseDetector(),
|
|
82
|
+
LeadingTrailingWhitespaceDetector(),
|
|
83
|
+
RepeatedWhitespaceDetector(),
|
|
84
|
+
HiddenControlCharacterDetector(),
|
|
85
|
+
UnusualLengthDetector(),
|
|
86
|
+
InvalidEmailDetector(),
|
|
87
|
+
InvalidPhoneDetector(),
|
|
88
|
+
InvalidUrlDetector(),
|
|
89
|
+
InvalidIpDetector(),
|
|
90
|
+
IqrOutlierDetector(),
|
|
91
|
+
PercentileOutlierDetector(),
|
|
92
|
+
ModifiedZScoreDetector(),
|
|
93
|
+
TailProbabilityDetector(),
|
|
94
|
+
HistogramRarityDetector(),
|
|
95
|
+
FormulaInjectionDetector(),
|
|
96
|
+
CrossFieldRuleDetector(),
|
|
97
|
+
InvalidDateDetector(),
|
|
98
|
+
ImpossibleDateDetector(),
|
|
99
|
+
FutureDateDetector(),
|
|
100
|
+
StaleDateDetector(),
|
|
101
|
+
MixedDateFormatDetector(),
|
|
102
|
+
DuplicateTimestampDetector(),
|
|
103
|
+
SpellingVariantDetector(),
|
|
104
|
+
FullwidthCharacterDetector(),
|
|
105
|
+
MojibakeCharacterDetector(),
|
|
106
|
+
InvalidNumericDetector(),
|
|
107
|
+
ForeignKeyViolationDetector(),
|
|
108
|
+
ModelOutlierDetector(),
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def register_default_detectors(registry: DetectorRegistry) -> list[Detector]:
|
|
113
|
+
"""向注册表注册首批检测器,返回已注册实例。"""
|
|
114
|
+
detectors = build_initial_detectors()
|
|
115
|
+
for detector in detectors:
|
|
116
|
+
registry.register(detector)
|
|
117
|
+
return detectors
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""机器学习异常检测器(Step 42):Isolation Forest / LOF,distribution_stability 维度。
|
|
2
|
+
|
|
3
|
+
与统计法(IQR/z-score)互补:模型法捕获任意形状分布中的偏离点。
|
|
4
|
+
每列独立建模(单变量);超过 max_samples 时随机采样训练/打分
|
|
5
|
+
(模型提示性信号,severity LOW,供人工确认,不冒充确证异常)。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import random
|
|
11
|
+
from typing import ClassVar
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from datasentry_core.detectors.base import DetectionContext
|
|
16
|
+
from datasentry_core.detectors.common import (
|
|
17
|
+
DetectorBase,
|
|
18
|
+
make_candidate,
|
|
19
|
+
make_evidence,
|
|
20
|
+
)
|
|
21
|
+
from datasentry_core.models.detector import DetectorCapabilities, IssueCandidate
|
|
22
|
+
from datasentry_core.models.enums import EvidenceType, QualityDimension, Severity
|
|
23
|
+
|
|
24
|
+
_NUMERIC_TYPES = frozenset(
|
|
25
|
+
{
|
|
26
|
+
"TINYINT",
|
|
27
|
+
"SMALLINT",
|
|
28
|
+
"INTEGER",
|
|
29
|
+
"BIGINT",
|
|
30
|
+
"HUGEINT",
|
|
31
|
+
"UTINYINT",
|
|
32
|
+
"USMALLINT",
|
|
33
|
+
"UINTEGER",
|
|
34
|
+
"UBIGINT",
|
|
35
|
+
"FLOAT",
|
|
36
|
+
"DOUBLE",
|
|
37
|
+
"DECIMAL",
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
_MAX_SAMPLES = 20_000
|
|
42
|
+
_MIN_ANOMALIES = 3
|
|
43
|
+
_MAX_EXAMPLES = 10
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _infer_columns(context: DetectionContext) -> list[str]:
|
|
47
|
+
return [
|
|
48
|
+
col.name
|
|
49
|
+
for col in context.handle.schema().columns
|
|
50
|
+
if col.physical_type.upper() in _NUMERIC_TYPES
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ModelOutlierDetector(DetectorBase):
|
|
55
|
+
"""单变量 IF/LOF 异常检测(distribution_stability)。
|
|
56
|
+
|
|
57
|
+
config["model"]: "isolation_forest"(默认)| "local_outlier_factor"。
|
|
58
|
+
anomaly_ratio:异常比例上限(默认 0.05),超过视为模型噪声不报。
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
detector_id = "model_outlier"
|
|
62
|
+
display_name = "Model Outlier (IF/LOF)"
|
|
63
|
+
description = (
|
|
64
|
+
"Flags points deviating from the main distribution using "
|
|
65
|
+
"Isolation Forest or LOF (single-column, sampled when large)."
|
|
66
|
+
)
|
|
67
|
+
quality_dimension = QualityDimension.DISTRIBUTION_STABILITY
|
|
68
|
+
capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(
|
|
69
|
+
supports_sampling=True,
|
|
70
|
+
requires_row_materialization=True,
|
|
71
|
+
)
|
|
72
|
+
default_thresholds: ClassVar[dict[str, float | int | str]] = {
|
|
73
|
+
"model": "isolation_forest",
|
|
74
|
+
"max_samples": _MAX_SAMPLES,
|
|
75
|
+
"min_anomalies": _MIN_ANOMALIES,
|
|
76
|
+
"anomaly_ratio": 0.05,
|
|
77
|
+
"contamination": 0.02,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
def __init__(self, model: str = "isolation_forest") -> None:
|
|
81
|
+
self._model = model
|
|
82
|
+
|
|
83
|
+
def supports(self, context: DetectionContext) -> bool:
|
|
84
|
+
return bool(_infer_columns(context))
|
|
85
|
+
|
|
86
|
+
def _detect_column(
|
|
87
|
+
self,
|
|
88
|
+
values: np.ndarray,
|
|
89
|
+
model: str,
|
|
90
|
+
max_samples: int,
|
|
91
|
+
seed: int,
|
|
92
|
+
contamination: float,
|
|
93
|
+
) -> tuple[int, float, list[float]]:
|
|
94
|
+
"""返回 (异常行数, 异常比例, 样例异常值)。"""
|
|
95
|
+
if len(values) < 8:
|
|
96
|
+
return 0, 0.0, []
|
|
97
|
+
rng = random.Random(seed)
|
|
98
|
+
if len(values) > max_samples:
|
|
99
|
+
values = np.array(rng.sample(list(values), max_samples))
|
|
100
|
+
from sklearn.ensemble import IsolationForest # type: ignore[import-untyped]
|
|
101
|
+
from sklearn.neighbors import LocalOutlierFactor # type: ignore[import-untyped]
|
|
102
|
+
|
|
103
|
+
x = values.reshape(-1, 1)
|
|
104
|
+
if model == "local_outlier_factor":
|
|
105
|
+
predictor = LocalOutlierFactor(
|
|
106
|
+
n_neighbors=min(20, max(5, len(x) // 10)),
|
|
107
|
+
contamination=contamination,
|
|
108
|
+
novelty=False,
|
|
109
|
+
)
|
|
110
|
+
pred = predictor.fit_predict(x)
|
|
111
|
+
else:
|
|
112
|
+
predictor = IsolationForest(
|
|
113
|
+
n_estimators=100,
|
|
114
|
+
contamination=contamination,
|
|
115
|
+
random_state=seed,
|
|
116
|
+
n_jobs=1,
|
|
117
|
+
)
|
|
118
|
+
pred = predictor.fit_predict(x)
|
|
119
|
+
anomaly_mask = pred == -1
|
|
120
|
+
count = int(anomaly_mask.sum())
|
|
121
|
+
if count == 0:
|
|
122
|
+
return 0, 0.0, []
|
|
123
|
+
examples = [float(v) for v in x[anomaly_mask][:_MAX_EXAMPLES, 0]]
|
|
124
|
+
return count, count / len(x), examples
|
|
125
|
+
|
|
126
|
+
def detect(self, context: DetectionContext) -> list[IssueCandidate]:
|
|
127
|
+
from datasentry_core.detectors.common import quote_ident
|
|
128
|
+
|
|
129
|
+
candidates: list[IssueCandidate] = []
|
|
130
|
+
model = context.config.detector_params.get("model") if context.config else None
|
|
131
|
+
model = model or self._model
|
|
132
|
+
max_samples = (
|
|
133
|
+
int(context.config.detector_params.get("max_samples", _MAX_SAMPLES))
|
|
134
|
+
if context.config
|
|
135
|
+
else _MAX_SAMPLES
|
|
136
|
+
)
|
|
137
|
+
min_anomalies = (
|
|
138
|
+
int(context.config.detector_params.get("min_anomalies", _MIN_ANOMALIES))
|
|
139
|
+
if context.config
|
|
140
|
+
else _MIN_ANOMALIES
|
|
141
|
+
)
|
|
142
|
+
max_ratio = (
|
|
143
|
+
float(context.config.detector_params.get("anomaly_ratio", 0.05))
|
|
144
|
+
if context.config
|
|
145
|
+
else 0.05
|
|
146
|
+
)
|
|
147
|
+
contamination = (
|
|
148
|
+
float(context.config.detector_params.get("contamination", 0.02))
|
|
149
|
+
if context.config
|
|
150
|
+
else 0.02
|
|
151
|
+
)
|
|
152
|
+
seed = context.config.seed if context.config else 42
|
|
153
|
+
for column in _infer_columns(context):
|
|
154
|
+
q = quote_ident(column)
|
|
155
|
+
try:
|
|
156
|
+
table = context.handle.sql_aggregate(
|
|
157
|
+
f"SELECT {q} AS v FROM data WHERE {q} IS NOT NULL"
|
|
158
|
+
).table
|
|
159
|
+
except Exception:
|
|
160
|
+
continue
|
|
161
|
+
values = np.asarray(table.column("v").to_pylist(), dtype=float)
|
|
162
|
+
values = values[np.isfinite(values)]
|
|
163
|
+
count, ratio, examples = self._detect_column(
|
|
164
|
+
values,
|
|
165
|
+
str(model),
|
|
166
|
+
max_samples,
|
|
167
|
+
seed,
|
|
168
|
+
contamination,
|
|
169
|
+
)
|
|
170
|
+
if count < min_anomalies or ratio > max_ratio:
|
|
171
|
+
continue
|
|
172
|
+
candidates.append(
|
|
173
|
+
make_candidate(
|
|
174
|
+
detector_id=self.detector_id,
|
|
175
|
+
detector_version=self.detector_version,
|
|
176
|
+
context=context,
|
|
177
|
+
issue_type="model_outlier",
|
|
178
|
+
columns=[column],
|
|
179
|
+
affected_count=count,
|
|
180
|
+
evidence=[
|
|
181
|
+
make_evidence(
|
|
182
|
+
detector_id=self.detector_id,
|
|
183
|
+
detector_version=self.detector_version,
|
|
184
|
+
evidence_type=EvidenceType.STATISTICAL_MEASURE,
|
|
185
|
+
description=(
|
|
186
|
+
f"{model}: {count} anomalies out of "
|
|
187
|
+
f"{len(values)} values ({ratio:.1%})"
|
|
188
|
+
),
|
|
189
|
+
data={
|
|
190
|
+
"model": model,
|
|
191
|
+
"anomaly_count": count,
|
|
192
|
+
"anomaly_ratio": round(ratio, 6),
|
|
193
|
+
"total_values": len(values),
|
|
194
|
+
"examples": [round(v, 4) for v in examples],
|
|
195
|
+
},
|
|
196
|
+
)
|
|
197
|
+
],
|
|
198
|
+
raw_score=count,
|
|
199
|
+
confidence=0.7,
|
|
200
|
+
severity=Severity.LOW,
|
|
201
|
+
fpr=0.3,
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
return candidates
|