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.
Files changed (80) hide show
  1. datasentry_core/__init__.py +111 -0
  2. datasentry_core/connectors/__init__.py +49 -0
  3. datasentry_core/connectors/base.py +108 -0
  4. datasentry_core/connectors/csv.py +312 -0
  5. datasentry_core/connectors/duckdb.py +105 -0
  6. datasentry_core/connectors/errors.py +19 -0
  7. datasentry_core/connectors/file_based.py +209 -0
  8. datasentry_core/connectors/jsonl.py +71 -0
  9. datasentry_core/connectors/parquet.py +64 -0
  10. datasentry_core/connectors/registry.py +55 -0
  11. datasentry_core/connectors/spec.py +37 -0
  12. datasentry_core/connectors/xlsx.py +109 -0
  13. datasentry_core/contracts/__init__.py +24 -0
  14. datasentry_core/contracts/exporters.py +330 -0
  15. datasentry_core/detectors/__init__.py +15 -0
  16. datasentry_core/detectors/base.py +120 -0
  17. datasentry_core/detectors/common.py +156 -0
  18. datasentry_core/detectors/cross_field.py +157 -0
  19. datasentry_core/detectors/cross_table.py +220 -0
  20. datasentry_core/detectors/datetime.py +343 -0
  21. datasentry_core/detectors/initial/__init__.py +117 -0
  22. datasentry_core/detectors/initial/anomaly_ml.py +204 -0
  23. datasentry_core/detectors/initial/categorical.py +252 -0
  24. datasentry_core/detectors/initial/formula.py +66 -0
  25. datasentry_core/detectors/initial/missing.py +150 -0
  26. datasentry_core/detectors/initial/numeric.py +302 -0
  27. datasentry_core/detectors/initial/textual.py +407 -0
  28. datasentry_core/detectors/initial/uniqueness.py +183 -0
  29. datasentry_core/detectors/missingness.py +424 -0
  30. datasentry_core/detectors/runner.py +228 -0
  31. datasentry_core/detectors/safe_eval.py +255 -0
  32. datasentry_core/detectors/textual_variants.py +300 -0
  33. datasentry_core/drift/__init__.py +8 -0
  34. datasentry_core/drift/engine.py +182 -0
  35. datasentry_core/engine/__init__.py +16 -0
  36. datasentry_core/engine/base.py +34 -0
  37. datasentry_core/engine/duckdb.py +79 -0
  38. datasentry_core/engine/fusion.py +193 -0
  39. datasentry_core/engine/profiler.py +165 -0
  40. datasentry_core/engine/sql_guard.py +23 -0
  41. datasentry_core/llm/provider.py +66 -0
  42. datasentry_core/models/__init__.py +118 -0
  43. datasentry_core/models/audit.py +46 -0
  44. datasentry_core/models/contract.py +89 -0
  45. datasentry_core/models/detector.py +66 -0
  46. datasentry_core/models/drift.py +47 -0
  47. datasentry_core/models/enums.py +126 -0
  48. datasentry_core/models/evidence.py +39 -0
  49. datasentry_core/models/feedback.py +30 -0
  50. datasentry_core/models/fingerprint.py +24 -0
  51. datasentry_core/models/issue.py +49 -0
  52. datasentry_core/models/llm.py +69 -0
  53. datasentry_core/models/profile.py +79 -0
  54. datasentry_core/models/quality.py +16 -0
  55. datasentry_core/models/repair.py +94 -0
  56. datasentry_core/models/rules.py +82 -0
  57. datasentry_core/models/scan.py +112 -0
  58. datasentry_core/models/validation.py +22 -0
  59. datasentry_core/plugins.py +91 -0
  60. datasentry_core/privacy/redactor.py +155 -0
  61. datasentry_core/repair/__init__.py +5 -0
  62. datasentry_core/repair/engine.py +455 -0
  63. datasentry_core/reporting/__init__.py +100 -0
  64. datasentry_core/reporting/html.py +230 -0
  65. datasentry_core/reporting/junit.py +80 -0
  66. datasentry_core/reporting/markdown.py +83 -0
  67. datasentry_core/reporting/sarif.py +92 -0
  68. datasentry_core/rules/engine.py +133 -0
  69. datasentry_core/scoring/__init__.py +30 -0
  70. datasentry_core/scoring/engine.py +138 -0
  71. datasentry_core/scoring/gate.py +96 -0
  72. datasentry_core/scoring/quality.py +137 -0
  73. datasentry_core/scoring/weights.py +33 -0
  74. datasentry_core/storage/__init__.py +15 -0
  75. datasentry_core/storage/paths.py +47 -0
  76. datasentry_core/storage/schema.py +343 -0
  77. datasentry_core/storage/store.py +675 -0
  78. datasentry_core-0.1.0.dist-info/METADATA +59 -0
  79. datasentry_core-0.1.0.dist-info/RECORD +80 -0
  80. 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