featuresmith-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.
- featuresmith/__init__.py +7 -0
- featuresmith/api.py +180 -0
- featuresmith/connectors/__init__.py +18 -0
- featuresmith/connectors/_paths.py +25 -0
- featuresmith/connectors/base.py +54 -0
- featuresmith/connectors/csv_connector.py +44 -0
- featuresmith/connectors/dataframe_connector.py +40 -0
- featuresmith/connectors/excel_connector.py +45 -0
- featuresmith/connectors/parquet_connector.py +44 -0
- featuresmith/connectors/registry.py +55 -0
- featuresmith/core/__init__.py +50 -0
- featuresmith/core/dataset.py +107 -0
- featuresmith/core/exceptions.py +17 -0
- featuresmith/core/profile_result.py +412 -0
- featuresmith/core/rule_finding.py +44 -0
- featuresmith/core/rule_result.py +49 -0
- featuresmith/core/schema.py +34 -0
- featuresmith/profiling/__init__.py +5 -0
- featuresmith/profiling/categorical.py +108 -0
- featuresmith/profiling/correlation.py +67 -0
- featuresmith/profiling/datetime.py +105 -0
- featuresmith/profiling/duplicates.py +48 -0
- featuresmith/profiling/missing.py +70 -0
- featuresmith/profiling/numeric.py +248 -0
- featuresmith/profiling/profiler.py +209 -0
- featuresmith/profiling/quality.py +29 -0
- featuresmith/profiling/summary.py +117 -0
- featuresmith/profiling/text.py +109 -0
- featuresmith/py.typed +1 -0
- featuresmith/rules/__init__.py +27 -0
- featuresmith/rules/base.py +69 -0
- featuresmith/rules/cardinality.py +95 -0
- featuresmith/rules/constants.py +115 -0
- featuresmith/rules/correlation.py +77 -0
- featuresmith/rules/duplicates.py +76 -0
- featuresmith/rules/engine.py +156 -0
- featuresmith/rules/leakage.py +93 -0
- featuresmith/rules/missing.py +83 -0
- featuresmith/rules/outliers.py +113 -0
- featuresmith/rules/registry.py +81 -0
- featuresmith_core-0.1.0.dist-info/METADATA +50 -0
- featuresmith_core-0.1.0.dist-info/RECORD +45 -0
- featuresmith_core-0.1.0.dist-info/WHEEL +5 -0
- featuresmith_core-0.1.0.dist-info/licenses/LICENSE +187 -0
- featuresmith_core-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Rules for constant and fully empty columns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from featuresmith.core.profile_result import ProfileResult
|
|
6
|
+
from featuresmith.core.rule_finding import RuleFinding
|
|
7
|
+
from featuresmith.rules.base import BaseRule
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConstantColumnsRule(BaseRule):
|
|
11
|
+
"""Flags columns that contain only one unique value (excluding fully empty)."""
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def id(self) -> str:
|
|
15
|
+
return "quality.constant_columns"
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def name(self) -> str:
|
|
19
|
+
return "Constant Columns"
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def description(self) -> str:
|
|
23
|
+
return "Detects columns containing only one unique value, offering no predictive power."
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def category(self) -> str:
|
|
27
|
+
return "quality"
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def severity(self) -> str:
|
|
31
|
+
return "warning"
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def enabled_by_default(self) -> bool:
|
|
35
|
+
return True
|
|
36
|
+
|
|
37
|
+
def evaluate(self, profile: ProfileResult) -> list[RuleFinding]:
|
|
38
|
+
findings: list[RuleFinding] = []
|
|
39
|
+
|
|
40
|
+
for col_name, col_prof in profile.column_profiles.items():
|
|
41
|
+
# A column is constant if it has only 1 unique value and is not fully empty
|
|
42
|
+
if col_prof.is_constant and not col_prof.is_fully_empty:
|
|
43
|
+
findings.append(
|
|
44
|
+
RuleFinding(
|
|
45
|
+
rule_id=self.id,
|
|
46
|
+
rule_name=self.name,
|
|
47
|
+
category=self.category,
|
|
48
|
+
severity=self.severity,
|
|
49
|
+
column_name=col_name,
|
|
50
|
+
title=f"Constant column '{col_name}'",
|
|
51
|
+
description=(
|
|
52
|
+
f"Column '{col_name}' contains only one unique value "
|
|
53
|
+
f"(excluding nulls) and provides no variance."
|
|
54
|
+
),
|
|
55
|
+
evidence={
|
|
56
|
+
"is_constant": True,
|
|
57
|
+
"is_fully_empty": False,
|
|
58
|
+
},
|
|
59
|
+
confidence=1.0,
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return findings
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class FullyEmptyColumnsRule(BaseRule):
|
|
67
|
+
"""Flags columns that contain only missing (null) values."""
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def id(self) -> str:
|
|
71
|
+
return "quality.fully_empty_columns"
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def name(self) -> str:
|
|
75
|
+
return "Fully Empty Columns"
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def description(self) -> str:
|
|
79
|
+
return "Detects columns containing only null values."
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def category(self) -> str:
|
|
83
|
+
return "quality"
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def severity(self) -> str:
|
|
87
|
+
return "critical"
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def enabled_by_default(self) -> bool:
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
def evaluate(self, profile: ProfileResult) -> list[RuleFinding]:
|
|
94
|
+
findings: list[RuleFinding] = []
|
|
95
|
+
|
|
96
|
+
for col_name, col_prof in profile.column_profiles.items():
|
|
97
|
+
if col_prof.is_fully_empty:
|
|
98
|
+
findings.append(
|
|
99
|
+
RuleFinding(
|
|
100
|
+
rule_id=self.id,
|
|
101
|
+
rule_name=self.name,
|
|
102
|
+
category=self.category,
|
|
103
|
+
severity=self.severity,
|
|
104
|
+
column_name=col_name,
|
|
105
|
+
title=f"Fully empty column '{col_name}'",
|
|
106
|
+
description=f"Column '{col_name}' contains only null/missing values.",
|
|
107
|
+
evidence={
|
|
108
|
+
"is_fully_empty": True,
|
|
109
|
+
"missing_percentage": 100.0,
|
|
110
|
+
},
|
|
111
|
+
confidence=1.0,
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return findings
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Rule for detecting highly correlated numeric feature pairs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from featuresmith.core.profile_result import ProfileResult
|
|
6
|
+
from featuresmith.core.rule_finding import RuleFinding
|
|
7
|
+
from featuresmith.rules.base import BaseRule
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HighCorrelationRule(BaseRule):
|
|
11
|
+
"""Flags pairs of numeric columns with Pearson correlation exceeding a threshold."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, threshold: float = 0.90) -> None:
|
|
14
|
+
"""Initialize the high correlation rule.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
threshold: Pearson correlation coefficient threshold (0.0 to 1.0).
|
|
18
|
+
"""
|
|
19
|
+
self.threshold = threshold
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def id(self) -> str:
|
|
23
|
+
return "statistical.high_correlation"
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def name(self) -> str:
|
|
27
|
+
return "High Correlation"
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def description(self) -> str:
|
|
31
|
+
return "Detects numeric feature pairs with high linear correlation (Pearson)."
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def category(self) -> str:
|
|
35
|
+
return "statistical"
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def severity(self) -> str:
|
|
39
|
+
return "warning"
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def enabled_by_default(self) -> bool:
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
def evaluate(self, profile: ProfileResult) -> list[RuleFinding]:
|
|
46
|
+
findings: list[RuleFinding] = []
|
|
47
|
+
pearson = profile.correlation_summary.pearson
|
|
48
|
+
|
|
49
|
+
columns = list(pearson.keys())
|
|
50
|
+
for i, col1 in enumerate(columns):
|
|
51
|
+
for col2 in columns[i + 1 :]:
|
|
52
|
+
corr = pearson[col1].get(col2)
|
|
53
|
+
if corr is not None and abs(corr) >= self.threshold:
|
|
54
|
+
findings.append(
|
|
55
|
+
RuleFinding(
|
|
56
|
+
rule_id=self.id,
|
|
57
|
+
rule_name=self.name,
|
|
58
|
+
category=self.category,
|
|
59
|
+
severity=self.severity,
|
|
60
|
+
column_name=col1, # Primary column is col1
|
|
61
|
+
title=f"High correlation between '{col1}' and '{col2}'",
|
|
62
|
+
description=(
|
|
63
|
+
f"Columns '{col1}' and '{col2}' are highly correlated "
|
|
64
|
+
f"with a Pearson correlation coefficient of {corr:.3f} "
|
|
65
|
+
f"(exceeds threshold of {self.threshold:.3f})."
|
|
66
|
+
),
|
|
67
|
+
evidence={
|
|
68
|
+
"column_a": col1,
|
|
69
|
+
"column_b": col2,
|
|
70
|
+
"correlation": corr,
|
|
71
|
+
"threshold": self.threshold,
|
|
72
|
+
},
|
|
73
|
+
confidence=1.0,
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
return findings
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Rule for detecting excessive duplicate rows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from featuresmith.core.profile_result import ProfileResult
|
|
6
|
+
from featuresmith.core.rule_finding import RuleFinding
|
|
7
|
+
from featuresmith.rules.base import BaseRule
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DuplicateRowsRule(BaseRule):
|
|
11
|
+
"""Flags datasets that contain an excessive percentage of duplicate rows."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, threshold: float = 10.0) -> None:
|
|
14
|
+
"""Initialize the duplicate rows rule.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
threshold: Duplicate rows percentage threshold (0.0 to 100.0).
|
|
18
|
+
|
|
19
|
+
Raises:
|
|
20
|
+
ValueError: If threshold is not between 0.0 and 100.0.
|
|
21
|
+
"""
|
|
22
|
+
if not (0.0 <= threshold <= 100.0):
|
|
23
|
+
raise ValueError("threshold must be a percentage between 0.0 and 100.0.")
|
|
24
|
+
self.threshold = threshold
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def id(self) -> str:
|
|
28
|
+
return "quality.duplicate_rows"
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def name(self) -> str:
|
|
32
|
+
return "Duplicate Rows"
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def description(self) -> str:
|
|
36
|
+
return "Detects if the percentage of duplicate rows exceeds a threshold."
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def category(self) -> str:
|
|
40
|
+
return "quality"
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def severity(self) -> str:
|
|
44
|
+
return "warning"
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def enabled_by_default(self) -> bool:
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
def evaluate(self, profile: ProfileResult) -> list[RuleFinding]:
|
|
51
|
+
findings: list[RuleFinding] = []
|
|
52
|
+
|
|
53
|
+
summary = profile.duplicate_summary
|
|
54
|
+
if summary.duplicate_percentage > self.threshold:
|
|
55
|
+
findings.append(
|
|
56
|
+
RuleFinding(
|
|
57
|
+
rule_id=self.id,
|
|
58
|
+
rule_name=self.name,
|
|
59
|
+
category=self.category,
|
|
60
|
+
severity=self.severity,
|
|
61
|
+
column_name=None,
|
|
62
|
+
title="Excessive duplicate rows detected",
|
|
63
|
+
description=(
|
|
64
|
+
f"The dataset contains {summary.duplicate_percentage:.2f}% duplicate rows "
|
|
65
|
+
f"({summary.duplicate_rows_count} rows), exceeding the threshold of {self.threshold:.2f}%."
|
|
66
|
+
),
|
|
67
|
+
evidence={
|
|
68
|
+
"duplicate_rows_count": summary.duplicate_rows_count,
|
|
69
|
+
"duplicate_percentage": summary.duplicate_percentage,
|
|
70
|
+
"threshold": self.threshold,
|
|
71
|
+
},
|
|
72
|
+
confidence=1.0,
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
return findings
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Orchestrator for executing deterministic rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import time
|
|
7
|
+
import traceback
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from featuresmith.core.profile_result import ProfileResult
|
|
11
|
+
from featuresmith.core.rule_finding import RuleFinding
|
|
12
|
+
from featuresmith.core.rule_result import RuleResult
|
|
13
|
+
from featuresmith.rules.registry import RuleRegistry, default_registry
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RuleEngine:
|
|
17
|
+
"""Orchestrates rule execution and generates a unified RuleResult."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, registry: RuleRegistry | None = None) -> None:
|
|
20
|
+
"""Initialize RuleEngine with a registry, defaulting to built-ins."""
|
|
21
|
+
self.registry = registry or default_registry()
|
|
22
|
+
|
|
23
|
+
def run(
|
|
24
|
+
self,
|
|
25
|
+
profile: ProfileResult,
|
|
26
|
+
*,
|
|
27
|
+
target_column: str | None = None,
|
|
28
|
+
enabled_rules: list[str] | None = None,
|
|
29
|
+
rule_config: dict[str, dict[str, Any]] | None = None,
|
|
30
|
+
) -> RuleResult:
|
|
31
|
+
"""Execute rules against the given ProfileResult.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
profile: The computed ProfileResult.
|
|
35
|
+
target_column: Optional name of the target column for leakage detection.
|
|
36
|
+
enabled_rules: Optional list of rule IDs to execute. If None, runs
|
|
37
|
+
all rules where enabled_by_default is True.
|
|
38
|
+
rule_config: Optional dictionary of rule configurations, keyed by rule ID.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
A RuleResult containing execution stats, findings, and any failures.
|
|
42
|
+
"""
|
|
43
|
+
# Validate rule configuration before execution
|
|
44
|
+
if rule_config:
|
|
45
|
+
for rule_id, config in rule_config.items():
|
|
46
|
+
rule = self.registry.get(rule_id)
|
|
47
|
+
if not rule:
|
|
48
|
+
raise ValueError(f"Unknown rule ID in config: '{rule_id}'")
|
|
49
|
+
|
|
50
|
+
sig = inspect.signature(rule.__class__)
|
|
51
|
+
try:
|
|
52
|
+
check_config = dict(config)
|
|
53
|
+
if (
|
|
54
|
+
rule_id == "leakage.potential_leakage"
|
|
55
|
+
and "target_column" not in check_config
|
|
56
|
+
and target_column is not None
|
|
57
|
+
):
|
|
58
|
+
check_config["target_column"] = target_column
|
|
59
|
+
|
|
60
|
+
# 1. Bind parameters to check names
|
|
61
|
+
bound = sig.bind(**check_config)
|
|
62
|
+
|
|
63
|
+
# 2. Check parameter types against annotations
|
|
64
|
+
from typing import get_type_hints
|
|
65
|
+
|
|
66
|
+
type_hints = get_type_hints(rule.__class__.__init__)
|
|
67
|
+
|
|
68
|
+
for param_name, param_value in bound.arguments.items():
|
|
69
|
+
if param_name in type_hints:
|
|
70
|
+
annotation = type_hints[param_name]
|
|
71
|
+
import types
|
|
72
|
+
from typing import Union, get_args, get_origin
|
|
73
|
+
|
|
74
|
+
def is_instance_of_type(val: Any, t: Any) -> bool:
|
|
75
|
+
origin = get_origin(t)
|
|
76
|
+
if origin is Union or origin is types.UnionType:
|
|
77
|
+
return any(
|
|
78
|
+
is_instance_of_type(val, arg)
|
|
79
|
+
for arg in get_args(t)
|
|
80
|
+
)
|
|
81
|
+
if t is Any:
|
|
82
|
+
return True
|
|
83
|
+
if t is None or t is type(None):
|
|
84
|
+
return val is None
|
|
85
|
+
if t is float and isinstance(val, int):
|
|
86
|
+
return True
|
|
87
|
+
if isinstance(t, type):
|
|
88
|
+
return isinstance(val, t)
|
|
89
|
+
return True
|
|
90
|
+
|
|
91
|
+
if not is_instance_of_type(param_value, annotation):
|
|
92
|
+
raise TypeError(
|
|
93
|
+
f"Incorrect type for parameter '{param_name}'. "
|
|
94
|
+
f"Expected {annotation}, got {type(param_value).__name__}."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# 3. Instantiate to check range/value constraints
|
|
98
|
+
_ = rule.__class__(**check_config)
|
|
99
|
+
except (TypeError, ValueError) as e:
|
|
100
|
+
raise type(e)(
|
|
101
|
+
f"Invalid configuration for rule '{rule_id}': {e}"
|
|
102
|
+
) from e
|
|
103
|
+
|
|
104
|
+
start_time = time.perf_counter()
|
|
105
|
+
|
|
106
|
+
findings: list[RuleFinding] = []
|
|
107
|
+
executed_rules: list[str] = []
|
|
108
|
+
failed_rules: dict[str, str] = {}
|
|
109
|
+
|
|
110
|
+
# Determine which rules to run
|
|
111
|
+
all_rules = self.registry.list_rules()
|
|
112
|
+
rules_to_run = []
|
|
113
|
+
for rule in all_rules:
|
|
114
|
+
if enabled_rules is not None:
|
|
115
|
+
if rule.id in enabled_rules:
|
|
116
|
+
rules_to_run.append(rule)
|
|
117
|
+
else:
|
|
118
|
+
if rule.enabled_by_default:
|
|
119
|
+
rules_to_run.append(rule)
|
|
120
|
+
|
|
121
|
+
for rule in rules_to_run:
|
|
122
|
+
try:
|
|
123
|
+
# Resolve rule configuration overrides
|
|
124
|
+
config = {}
|
|
125
|
+
if rule_config and rule.id in rule_config:
|
|
126
|
+
config.update(rule_config[rule.id])
|
|
127
|
+
|
|
128
|
+
# Inject target column for leakage detection rule
|
|
129
|
+
if rule.id == "leakage.potential_leakage" and target_column is not None:
|
|
130
|
+
config["target_column"] = target_column
|
|
131
|
+
|
|
132
|
+
# If we have configuration, instantiate a new configured instance of the rule
|
|
133
|
+
if config:
|
|
134
|
+
rule_instance = rule.__class__(**config)
|
|
135
|
+
else:
|
|
136
|
+
rule_instance = rule
|
|
137
|
+
|
|
138
|
+
# Evaluate the rule
|
|
139
|
+
rule_findings = rule_instance.evaluate(profile)
|
|
140
|
+
findings.extend(rule_findings)
|
|
141
|
+
executed_rules.append(rule.id)
|
|
142
|
+
except Exception as e:
|
|
143
|
+
# Isolate rule failure to prevent crashing the whole pipeline
|
|
144
|
+
failed_rules[rule.id] = "".join(
|
|
145
|
+
traceback.format_exception(type(e), e, e.__traceback__)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
execution_time_ms = (time.perf_counter() - start_time) * 1000.0
|
|
149
|
+
|
|
150
|
+
return RuleResult(
|
|
151
|
+
profile=profile,
|
|
152
|
+
findings=findings,
|
|
153
|
+
executed_rules=executed_rules,
|
|
154
|
+
execution_time_ms=execution_time_ms,
|
|
155
|
+
failed_rules=failed_rules,
|
|
156
|
+
)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Rule for detecting potential target leakage using high correlation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from featuresmith.core.profile_result import ProfileResult
|
|
6
|
+
from featuresmith.core.rule_finding import RuleFinding
|
|
7
|
+
from featuresmith.rules.base import BaseRule
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LeakageRuleTargetCorrelation(BaseRule):
|
|
11
|
+
"""Flags columns that have an extremely high correlation with the target column."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self, target_column: str | None = None, threshold: float = 0.99
|
|
15
|
+
) -> None:
|
|
16
|
+
"""Initialize the target correlation leakage rule.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
target_column: The name of the target column. If None, the rule will skip execution.
|
|
20
|
+
threshold: Pearson correlation coefficient threshold (default 0.99).
|
|
21
|
+
"""
|
|
22
|
+
self.target_column = target_column
|
|
23
|
+
self.threshold = threshold
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def id(self) -> str:
|
|
27
|
+
return "leakage.potential_leakage"
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def name(self) -> str:
|
|
31
|
+
return "Potential Target Leakage"
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def description(self) -> str:
|
|
35
|
+
return (
|
|
36
|
+
"Detects columns that are extremely highly correlated with the target column, "
|
|
37
|
+
"which often indicates they contain information from the future (leakage)."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def category(self) -> str:
|
|
42
|
+
return "leakage"
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def severity(self) -> str:
|
|
46
|
+
return "critical"
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def enabled_by_default(self) -> bool:
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
def evaluate(self, profile: ProfileResult) -> list[RuleFinding]:
|
|
53
|
+
findings: list[RuleFinding] = []
|
|
54
|
+
|
|
55
|
+
if self.target_column is None:
|
|
56
|
+
# Without a target column, we do not infer one, per "No target inference" requirement.
|
|
57
|
+
return findings
|
|
58
|
+
|
|
59
|
+
pearson = profile.correlation_summary.pearson
|
|
60
|
+
|
|
61
|
+
# Verify target column is numeric/has computed correlations
|
|
62
|
+
if self.target_column not in pearson:
|
|
63
|
+
return findings
|
|
64
|
+
|
|
65
|
+
for col_name in pearson:
|
|
66
|
+
if col_name == self.target_column:
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
corr = pearson[col_name].get(self.target_column)
|
|
70
|
+
if corr is not None and abs(corr) >= self.threshold:
|
|
71
|
+
findings.append(
|
|
72
|
+
RuleFinding(
|
|
73
|
+
rule_id=self.id,
|
|
74
|
+
rule_name=self.name,
|
|
75
|
+
category=self.category,
|
|
76
|
+
severity=self.severity,
|
|
77
|
+
column_name=col_name,
|
|
78
|
+
title=f"Potential target leakage in '{col_name}'",
|
|
79
|
+
description=(
|
|
80
|
+
f"Column '{col_name}' is extremely highly correlated with "
|
|
81
|
+
f"target '{self.target_column}' (Pearson correlation = {corr:.3f}, "
|
|
82
|
+
f"threshold {self.threshold:.3f}), which suggests potential target leakage."
|
|
83
|
+
),
|
|
84
|
+
evidence={
|
|
85
|
+
"target_column": self.target_column,
|
|
86
|
+
"correlation": corr,
|
|
87
|
+
"threshold": self.threshold,
|
|
88
|
+
},
|
|
89
|
+
confidence=1.0,
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
return findings
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Rule for detecting columns with missing values exceeding a threshold."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from featuresmith.core.profile_result import ProfileResult
|
|
6
|
+
from featuresmith.core.rule_finding import RuleFinding
|
|
7
|
+
from featuresmith.rules.base import BaseRule
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MissingValueThresholdRule(BaseRule):
|
|
11
|
+
"""Flags columns that exceed a configurable missing-value threshold."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, threshold: float = 20.0) -> None:
|
|
14
|
+
"""Initialize the missing value rule.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
threshold: Missing value percentage threshold (0.0 to 100.0).
|
|
18
|
+
|
|
19
|
+
Raises:
|
|
20
|
+
ValueError: If threshold is not between 0.0 and 100.0.
|
|
21
|
+
"""
|
|
22
|
+
if not (0.0 <= threshold <= 100.0):
|
|
23
|
+
raise ValueError("threshold must be a percentage between 0.0 and 100.0.")
|
|
24
|
+
self.threshold = threshold
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def id(self) -> str:
|
|
28
|
+
return "quality.missing_value_threshold"
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def name(self) -> str:
|
|
32
|
+
return "Missing Value Threshold"
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def description(self) -> str:
|
|
36
|
+
return (
|
|
37
|
+
"Detects columns where the percentage of missing values "
|
|
38
|
+
"exceeds a configurable threshold."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def category(self) -> str:
|
|
43
|
+
return "quality"
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def severity(self) -> str:
|
|
47
|
+
return "warning"
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def enabled_by_default(self) -> bool:
|
|
51
|
+
return True
|
|
52
|
+
|
|
53
|
+
def evaluate(self, profile: ProfileResult) -> list[RuleFinding]:
|
|
54
|
+
findings: list[RuleFinding] = []
|
|
55
|
+
|
|
56
|
+
for col_name, col_prof in profile.column_profiles.items():
|
|
57
|
+
if col_prof.missing_percentage > self.threshold:
|
|
58
|
+
# Severity can escalate to critical if missingness is extremely high (e.g. > 50%)
|
|
59
|
+
severity = (
|
|
60
|
+
"critical" if col_prof.missing_percentage > 50.0 else self.severity
|
|
61
|
+
)
|
|
62
|
+
findings.append(
|
|
63
|
+
RuleFinding(
|
|
64
|
+
rule_id=self.id,
|
|
65
|
+
rule_name=self.name,
|
|
66
|
+
category=self.category,
|
|
67
|
+
severity=severity,
|
|
68
|
+
column_name=col_name,
|
|
69
|
+
title=f"High missing values in column '{col_name}'",
|
|
70
|
+
description=(
|
|
71
|
+
f"Column '{col_name}' has {col_prof.missing_percentage:.2f}% "
|
|
72
|
+
f"missing values, exceeding the threshold of {self.threshold:.2f}%."
|
|
73
|
+
),
|
|
74
|
+
evidence={
|
|
75
|
+
"missing_count": col_prof.missing_count,
|
|
76
|
+
"missing_percentage": col_prof.missing_percentage,
|
|
77
|
+
"threshold": self.threshold,
|
|
78
|
+
},
|
|
79
|
+
confidence=1.0,
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
return findings
|