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.
Files changed (45) hide show
  1. featuresmith/__init__.py +7 -0
  2. featuresmith/api.py +180 -0
  3. featuresmith/connectors/__init__.py +18 -0
  4. featuresmith/connectors/_paths.py +25 -0
  5. featuresmith/connectors/base.py +54 -0
  6. featuresmith/connectors/csv_connector.py +44 -0
  7. featuresmith/connectors/dataframe_connector.py +40 -0
  8. featuresmith/connectors/excel_connector.py +45 -0
  9. featuresmith/connectors/parquet_connector.py +44 -0
  10. featuresmith/connectors/registry.py +55 -0
  11. featuresmith/core/__init__.py +50 -0
  12. featuresmith/core/dataset.py +107 -0
  13. featuresmith/core/exceptions.py +17 -0
  14. featuresmith/core/profile_result.py +412 -0
  15. featuresmith/core/rule_finding.py +44 -0
  16. featuresmith/core/rule_result.py +49 -0
  17. featuresmith/core/schema.py +34 -0
  18. featuresmith/profiling/__init__.py +5 -0
  19. featuresmith/profiling/categorical.py +108 -0
  20. featuresmith/profiling/correlation.py +67 -0
  21. featuresmith/profiling/datetime.py +105 -0
  22. featuresmith/profiling/duplicates.py +48 -0
  23. featuresmith/profiling/missing.py +70 -0
  24. featuresmith/profiling/numeric.py +248 -0
  25. featuresmith/profiling/profiler.py +209 -0
  26. featuresmith/profiling/quality.py +29 -0
  27. featuresmith/profiling/summary.py +117 -0
  28. featuresmith/profiling/text.py +109 -0
  29. featuresmith/py.typed +1 -0
  30. featuresmith/rules/__init__.py +27 -0
  31. featuresmith/rules/base.py +69 -0
  32. featuresmith/rules/cardinality.py +95 -0
  33. featuresmith/rules/constants.py +115 -0
  34. featuresmith/rules/correlation.py +77 -0
  35. featuresmith/rules/duplicates.py +76 -0
  36. featuresmith/rules/engine.py +156 -0
  37. featuresmith/rules/leakage.py +93 -0
  38. featuresmith/rules/missing.py +83 -0
  39. featuresmith/rules/outliers.py +113 -0
  40. featuresmith/rules/registry.py +81 -0
  41. featuresmith_core-0.1.0.dist-info/METADATA +50 -0
  42. featuresmith_core-0.1.0.dist-info/RECORD +45 -0
  43. featuresmith_core-0.1.0.dist-info/WHEEL +5 -0
  44. featuresmith_core-0.1.0.dist-info/licenses/LICENSE +187 -0
  45. 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