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,209 @@
1
+ """Main orchestration module for profiling datasets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from datetime import UTC, datetime
7
+
8
+ import polars as pl
9
+
10
+ from featuresmith.core.dataset import Dataset
11
+ from featuresmith.core.profile_result import (
12
+ ColumnProfile,
13
+ DatasetSummary,
14
+ ExecutionMetadata,
15
+ ProfileResult,
16
+ )
17
+ from featuresmith.profiling.categorical import profile_categorical_column
18
+ from featuresmith.profiling.correlation import compute_correlations
19
+ from featuresmith.profiling.datetime import profile_datetime_column
20
+ from featuresmith.profiling.duplicates import analyze_duplicates
21
+ from featuresmith.profiling.missing import analyze_missing_values
22
+ from featuresmith.profiling.numeric import profile_numeric_column
23
+ from featuresmith.profiling.quality import (
24
+ find_constant_columns,
25
+ find_fully_empty_columns,
26
+ )
27
+ from featuresmith.profiling.summary import build_dataset_metadata, classify_logical_type
28
+ from featuresmith.profiling.text import profile_text_column
29
+
30
+
31
+ def _get_non_null_unique_count(dataset: Dataset, col_name: str) -> int:
32
+ """Compute the number of unique non-null values in a column."""
33
+ df = dataset.dataframe
34
+ if dataset.backend == "pandas":
35
+ return int(df[col_name].dropna().nunique())
36
+ else:
37
+ # Polars
38
+ return int(df.select(pl.col(col_name).drop_nulls().n_unique())[0, 0])
39
+
40
+
41
+ def _get_missing_count(dataset: Dataset, col_name: str) -> int:
42
+ """Compute the number of missing values in a column."""
43
+ df = dataset.dataframe
44
+ if dataset.backend == "pandas":
45
+ return int(df[col_name].isna().sum())
46
+ else:
47
+ # Polars
48
+ return int(df.select(pl.col(col_name).null_count())[0, 0])
49
+
50
+
51
+ def profile_dataset(
52
+ dataset: Dataset,
53
+ max_correlation_columns: int = 100,
54
+ max_frequency_table_size: int = 1000,
55
+ ) -> ProfileResult:
56
+ """Orchestrate deterministic profiling of a dataset.
57
+
58
+ Args:
59
+ dataset: The normalized Dataset object to profile.
60
+ max_correlation_columns: Limit correlation computations to prevent
61
+ combinatorial blowup (default 100).
62
+
63
+ Returns:
64
+ ProfileResult: A strongly-typed statistical profile containing dataset-wide
65
+ summaries, column-level profiles, missingness summaries, duplicate
66
+ statistics, and a Pearson correlation matrix.
67
+
68
+ Notes:
69
+ Logical type classification uses a heuristic mapping columns into
70
+ "numeric", "categorical", "datetime", or "text". Pearson correlation
71
+ matrix is computed for numeric columns only and is capped to the first
72
+ `max_correlation_columns` numeric columns to prevent performance degradation.
73
+
74
+ Examples:
75
+ >>> import pandas as pd
76
+ >>> from featuresmith.core.dataset import Dataset
77
+ >>> from featuresmith.profiling.profiler import profile_dataset
78
+ >>> df = pd.DataFrame({"a": [1, 2, 3]})
79
+ >>> ds = Dataset.from_dataframe(df, backend="pandas")
80
+ >>> prof = profile_dataset(ds)
81
+ >>> prof.dataset_summary.row_count
82
+ 3
83
+ """
84
+ start_time_iso = datetime.now(UTC).isoformat()
85
+ start_perf = time.perf_counter()
86
+
87
+ row_count = dataset.row_count
88
+ column_count = dataset.column_count
89
+
90
+ numeric_profiles = {}
91
+ categorical_profiles = {}
92
+ datetime_profiles = {}
93
+ text_profiles = {}
94
+ column_profiles = {}
95
+
96
+ # 1. Profile each column individually based on classified logical type
97
+ for col_name in dataset.schema.names:
98
+ logical_type = classify_logical_type(dataset, col_name)
99
+ dtype_str = dataset.dtypes[col_name]
100
+
101
+ if logical_type == "numeric":
102
+ num_prof = profile_numeric_column(dataset, col_name)
103
+ numeric_profiles[col_name] = num_prof
104
+
105
+ missing_count = num_prof.missing_count
106
+ missing_percentage = num_prof.missing_percentage
107
+ is_constant = num_prof.unique_count <= 1
108
+ is_fully_empty = num_prof.count == 0
109
+
110
+ elif logical_type == "categorical":
111
+ cat_prof = profile_categorical_column(
112
+ dataset, col_name, max_frequency_table_size=max_frequency_table_size
113
+ )
114
+ categorical_profiles[col_name] = cat_prof
115
+
116
+ missing_count = cat_prof.missing_count
117
+ missing_percentage = (
118
+ float((missing_count / row_count) * 100.0) if row_count > 0 else 0.0
119
+ )
120
+ is_constant = cat_prof.unique_count <= 1
121
+ is_fully_empty = cat_prof.cardinality == 0 and missing_count == row_count
122
+
123
+ elif logical_type == "datetime":
124
+ dt_prof = profile_datetime_column(dataset, col_name)
125
+ datetime_profiles[col_name] = dt_prof
126
+
127
+ missing_count = dt_prof.missing_count
128
+ missing_percentage = (
129
+ float((missing_count / row_count) * 100.0) if row_count > 0 else 0.0
130
+ )
131
+ is_constant = dt_prof.minimum == dt_prof.maximum or (
132
+ dt_prof.minimum is None and dt_prof.maximum is None
133
+ )
134
+ is_fully_empty = dt_prof.minimum is None and dt_prof.maximum is None
135
+
136
+ else: # logical_type == "text"
137
+ txt_prof = profile_text_column(dataset, col_name)
138
+ text_profiles[col_name] = txt_prof
139
+
140
+ missing_count = _get_missing_count(dataset, col_name)
141
+ missing_percentage = (
142
+ float((missing_count / row_count) * 100.0) if row_count > 0 else 0.0
143
+ )
144
+ unique_cnt = _get_non_null_unique_count(dataset, col_name)
145
+ is_constant = unique_cnt <= 1
146
+ is_fully_empty = txt_prof.avg_length is None and missing_count == row_count
147
+
148
+ column_profiles[col_name] = ColumnProfile(
149
+ name=col_name,
150
+ dtype=dtype_str,
151
+ logical_type=logical_type,
152
+ missing_count=missing_count,
153
+ missing_percentage=missing_percentage,
154
+ is_constant=is_constant,
155
+ is_fully_empty=is_fully_empty,
156
+ )
157
+
158
+ # 2. Identify constant and fully empty columns across the dataset
159
+ constant_columns = find_constant_columns(column_profiles)
160
+ fully_empty_columns = find_fully_empty_columns(column_profiles)
161
+
162
+ # 3. Compute summaries
163
+ missing_value_summary = analyze_missing_values(dataset)
164
+ duplicate_summary = analyze_duplicates(
165
+ dataset, constant_columns, fully_empty_columns
166
+ )
167
+ correlation_summary = compute_correlations(
168
+ dataset, list(numeric_profiles.keys()), max_correlation_columns
169
+ )
170
+ dataset_metadata = build_dataset_metadata(dataset)
171
+
172
+ # 4. Global counts
173
+ dataset_summary = DatasetSummary(
174
+ row_count=row_count,
175
+ column_count=column_count,
176
+ size_in_bytes=dataset.file_size,
177
+ missing_percentage=missing_value_summary.dataset_missing_percentage,
178
+ duplicate_percentage=duplicate_summary.duplicate_percentage,
179
+ num_numeric_columns=len(numeric_profiles),
180
+ num_categorical_columns=len(categorical_profiles),
181
+ num_datetime_columns=len(datetime_profiles),
182
+ num_text_columns=len(text_profiles),
183
+ num_constant_columns=len(constant_columns),
184
+ num_fully_empty_columns=len(fully_empty_columns),
185
+ )
186
+
187
+ elapsed_time = time.perf_counter() - start_perf
188
+
189
+ from featuresmith import __version__ as pkg_version
190
+
191
+ execution_metadata = ExecutionMetadata(
192
+ start_time=start_time_iso,
193
+ duration_seconds=elapsed_time,
194
+ featuresmith_version=pkg_version,
195
+ )
196
+
197
+ return ProfileResult(
198
+ dataset_summary=dataset_summary,
199
+ column_profiles=column_profiles,
200
+ numeric_profiles=numeric_profiles,
201
+ categorical_profiles=categorical_profiles,
202
+ datetime_profiles=datetime_profiles,
203
+ text_profiles=text_profiles,
204
+ missing_value_summary=missing_value_summary,
205
+ duplicate_summary=duplicate_summary,
206
+ correlation_summary=correlation_summary,
207
+ dataset_metadata=dataset_metadata,
208
+ execution_metadata=execution_metadata,
209
+ )
@@ -0,0 +1,29 @@
1
+ """Quality analysis utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from featuresmith.core.profile_result import ColumnProfile
6
+
7
+
8
+ def find_constant_columns(column_profiles: dict[str, ColumnProfile]) -> list[str]:
9
+ """Identify columns that contain constant values.
10
+
11
+ Args:
12
+ column_profiles: Mapping of column name to ColumnProfile.
13
+
14
+ Returns:
15
+ List of constant column names.
16
+ """
17
+ return [name for name, profile in column_profiles.items() if profile.is_constant]
18
+
19
+
20
+ def find_fully_empty_columns(column_profiles: dict[str, ColumnProfile]) -> list[str]:
21
+ """Identify columns that are completely empty (all missing).
22
+
23
+ Args:
24
+ column_profiles: Mapping of column name to ColumnProfile.
25
+
26
+ Returns:
27
+ List of fully empty column names.
28
+ """
29
+ return [name for name, profile in column_profiles.items() if profile.is_fully_empty]
@@ -0,0 +1,117 @@
1
+ """Helper functions for logical type classification and dataset summaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pandas as pd
6
+ import polars as pl
7
+
8
+ from featuresmith.core.dataset import Dataset
9
+ from featuresmith.core.profile_result import DatasetMetadata
10
+
11
+
12
+ def classify_logical_type(dataset: Dataset, col_name: str) -> str:
13
+ """Classify the logical type of a column.
14
+
15
+ Args:
16
+ dataset: The normalized dataset.
17
+ col_name: Name of the column to classify.
18
+
19
+ Returns:
20
+ One of "numeric", "categorical", "datetime", or "text".
21
+ """
22
+ df = dataset.dataframe
23
+
24
+ # 1. Datetime detection
25
+ if dataset.backend == "pandas":
26
+ series = df[col_name]
27
+ if pd.api.types.is_datetime64_any_dtype(series):
28
+ return "datetime"
29
+ elif dataset.backend == "polars":
30
+ col_type = df.schema[col_name]
31
+ if col_type in (pl.Date, pl.Datetime, pl.Time, pl.Duration):
32
+ return "datetime"
33
+
34
+ # 2. Numeric detection
35
+ if dataset.backend == "pandas":
36
+ series = df[col_name]
37
+ if pd.api.types.is_numeric_dtype(series) and not pd.api.types.is_bool_dtype(
38
+ series
39
+ ):
40
+ return "numeric"
41
+ elif dataset.backend == "polars":
42
+ col_type = df.schema[col_name]
43
+ if col_type.is_numeric():
44
+ return "numeric"
45
+
46
+ # 3. Boolean/Categorical/String detection
47
+ if dataset.backend == "pandas":
48
+ series = df[col_name]
49
+ if pd.api.types.is_bool_dtype(series):
50
+ return "categorical"
51
+ elif dataset.backend == "polars":
52
+ col_type = df.schema[col_name]
53
+ if col_type == pl.Boolean:
54
+ return "categorical"
55
+
56
+ # If it is category, object, string or anything else, we analyze string properties
57
+ # Let's count unique values and average string length.
58
+ if dataset.backend == "pandas":
59
+ series = df[col_name].dropna()
60
+ if len(series) == 0:
61
+ return "categorical"
62
+
63
+ # Convert to string to check length
64
+ str_series = series.astype(str)
65
+ total_len = str_series.str.len().sum()
66
+ avg_len = total_len / len(series)
67
+ unique_count = str_series.nunique()
68
+ non_null_count = len(series)
69
+ else:
70
+ # Polars
71
+ series = df.select(pl.col(col_name).drop_nulls())
72
+ non_null_count = len(series)
73
+ if non_null_count == 0:
74
+ return "categorical"
75
+
76
+ # Compute stats in Polars
77
+ stats = series.select(
78
+ [
79
+ pl.col(col_name)
80
+ .cast(pl.String)
81
+ .str.len_chars()
82
+ .mean()
83
+ .alias("avg_len"),
84
+ pl.col(col_name).cast(pl.String).n_unique().alias("unique_count"),
85
+ ]
86
+ )
87
+ avg_len = stats.get_column("avg_len")[0] or 0.0
88
+ unique_count = stats.get_column("unique_count")[0] or 0
89
+
90
+ # Heuristic for Text vs Categorical
91
+ if avg_len >= 20:
92
+ return "text"
93
+ if (
94
+ non_null_count > 0
95
+ and (unique_count / non_null_count) > 0.5
96
+ and unique_count > 10
97
+ ):
98
+ return "text"
99
+
100
+ return "categorical"
101
+
102
+
103
+ def build_dataset_metadata(dataset: Dataset) -> DatasetMetadata:
104
+ """Build the DatasetMetadata structure.
105
+
106
+ Args:
107
+ dataset: The normalized dataset.
108
+
109
+ Returns:
110
+ A DatasetMetadata object.
111
+ """
112
+ return DatasetMetadata(
113
+ source=dataset.source,
114
+ file_size=dataset.file_size,
115
+ backend=dataset.backend,
116
+ custom_metadata=dict(dataset.metadata),
117
+ )
@@ -0,0 +1,109 @@
1
+ """Text column profiling module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import polars as pl
6
+
7
+ from featuresmith.core.dataset import Dataset
8
+ from featuresmith.core.profile_result import TextProfile
9
+
10
+
11
+ def profile_text_column(dataset: Dataset, col_name: str) -> TextProfile:
12
+ """Profile a single text column.
13
+
14
+ Args:
15
+ dataset: The normalized dataset.
16
+ col_name: Name of the column.
17
+
18
+ Returns:
19
+ A TextProfile object.
20
+ """
21
+ df = dataset.dataframe
22
+ row_count = dataset.row_count
23
+
24
+ if dataset.backend == "pandas":
25
+ series = df[col_name]
26
+ non_null_series = series.dropna().astype(str)
27
+ count = len(non_null_series)
28
+
29
+ if count == 0:
30
+ return TextProfile(
31
+ column_name=col_name,
32
+ avg_length=None,
33
+ min_length=None,
34
+ max_length=None,
35
+ empty_strings=int((series == "").sum()) if row_count > 0 else 0,
36
+ whitespace_only=0,
37
+ char_count=0,
38
+ word_count=0,
39
+ )
40
+
41
+ lengths = non_null_series.str.len()
42
+ avg_length = float(lengths.mean())
43
+ min_length = int(lengths.min())
44
+ max_length = int(lengths.max())
45
+
46
+ # Count empty strings in the entire series (including any null/nan that might be empty? No, exact matches to "")
47
+ empty_strings = int((series == "").sum())
48
+
49
+ # Whitespace-only matches: one or more whitespace characters
50
+ whitespace_only = int(non_null_series.str.match(r"^\s+$").sum())
51
+ char_count = int(lengths.sum())
52
+
53
+ # Word counts: find all non-whitespace chunks and sum their counts
54
+ word_count = int(non_null_series.str.findall(r"\S+").str.len().sum())
55
+
56
+ else:
57
+ # Polars
58
+ col = pl.col(col_name)
59
+ non_null_col = col.drop_nulls().cast(pl.String)
60
+
61
+ # Select all stats in one pass
62
+ res = df.select(
63
+ [
64
+ non_null_col.len().alias("count"),
65
+ non_null_col.str.len_chars().mean().alias("avg_len"),
66
+ non_null_col.str.len_chars().min().alias("min_len"),
67
+ non_null_col.str.len_chars().max().alias("max_len"),
68
+ (col == "").sum().alias("empty_strings"),
69
+ col.str.contains(r"^\s+$").sum().alias("whitespace_only"),
70
+ non_null_col.str.len_chars().sum().alias("char_count"),
71
+ non_null_col.str.extract_all(r"\S+")
72
+ .list.len()
73
+ .sum()
74
+ .alias("word_count"),
75
+ ]
76
+ )
77
+
78
+ count = int(res.get_column("count")[0])
79
+ empty_strings = int(res.get_column("empty_strings")[0] or 0)
80
+ whitespace_only = int(res.get_column("whitespace_only")[0] or 0)
81
+
82
+ if count == 0:
83
+ return TextProfile(
84
+ column_name=col_name,
85
+ avg_length=None,
86
+ min_length=None,
87
+ max_length=None,
88
+ empty_strings=empty_strings,
89
+ whitespace_only=whitespace_only,
90
+ char_count=0,
91
+ word_count=0,
92
+ )
93
+
94
+ avg_length = float(res.get_column("avg_len")[0])
95
+ min_length = int(res.get_column("min_len")[0])
96
+ max_length = int(res.get_column("max_len")[0])
97
+ char_count = int(res.get_column("char_count")[0])
98
+ word_count = int(res.get_column("word_count")[0] or 0)
99
+
100
+ return TextProfile(
101
+ column_name=col_name,
102
+ avg_length=avg_length,
103
+ min_length=min_length,
104
+ max_length=max_length,
105
+ empty_strings=empty_strings,
106
+ whitespace_only=whitespace_only,
107
+ char_count=char_count,
108
+ word_count=word_count,
109
+ )
featuresmith/py.typed ADDED
@@ -0,0 +1 @@
1
+ # Marker file for PEP 561.
@@ -0,0 +1,27 @@
1
+ """Rule engine and deterministic rule definitions for Featuresmith."""
2
+
3
+ from featuresmith.rules.base import BaseRule
4
+ from featuresmith.rules.cardinality import HighCardinalityRule
5
+ from featuresmith.rules.constants import ConstantColumnsRule, FullyEmptyColumnsRule
6
+ from featuresmith.rules.correlation import HighCorrelationRule
7
+ from featuresmith.rules.duplicates import DuplicateRowsRule
8
+ from featuresmith.rules.engine import RuleEngine
9
+ from featuresmith.rules.leakage import LeakageRuleTargetCorrelation
10
+ from featuresmith.rules.missing import MissingValueThresholdRule
11
+ from featuresmith.rules.outliers import OutlierDetectionRule
12
+ from featuresmith.rules.registry import RuleRegistry, default_registry
13
+
14
+ __all__ = [
15
+ "BaseRule",
16
+ "RuleEngine",
17
+ "RuleRegistry",
18
+ "default_registry",
19
+ "MissingValueThresholdRule",
20
+ "DuplicateRowsRule",
21
+ "ConstantColumnsRule",
22
+ "FullyEmptyColumnsRule",
23
+ "HighCardinalityRule",
24
+ "OutlierDetectionRule",
25
+ "HighCorrelationRule",
26
+ "LeakageRuleTargetCorrelation",
27
+ ]
@@ -0,0 +1,69 @@
1
+ """Abstract base class for all Featuresmith rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import abc
6
+
7
+ from featuresmith.core.profile_result import ProfileResult
8
+ from featuresmith.core.rule_finding import RuleFinding
9
+
10
+
11
+ class BaseRule(abc.ABC):
12
+ """Abstract base class for deterministic rule evaluation.
13
+
14
+ Attributes:
15
+ id: Unique stable identifier for the rule (e.g. "quality.missing_value_threshold").
16
+ name: Short human-readable name for the rule.
17
+ description: High-level description of what the rule flags.
18
+ category: Category of the rule ("quality", "statistical", "leakage").
19
+ severity: Default severity level ("info", "warning", "critical").
20
+ enabled_by_default: True if the rule runs by default.
21
+ """
22
+
23
+ @property
24
+ @abc.abstractmethod
25
+ def id(self) -> str:
26
+ """The stable namespaced identifier of the rule."""
27
+ pass
28
+
29
+ @property
30
+ @abc.abstractmethod
31
+ def name(self) -> str:
32
+ """A human-readable name of the rule."""
33
+ pass
34
+
35
+ @property
36
+ @abc.abstractmethod
37
+ def description(self) -> str:
38
+ """A detailed description of the rule's purpose."""
39
+ pass
40
+
41
+ @property
42
+ @abc.abstractmethod
43
+ def category(self) -> str:
44
+ """The category of the rule: 'quality', 'statistical', or 'leakage'."""
45
+ pass
46
+
47
+ @property
48
+ @abc.abstractmethod
49
+ def severity(self) -> str:
50
+ """The severity level: 'info', 'warning', or 'critical'."""
51
+ pass
52
+
53
+ @property
54
+ @abc.abstractmethod
55
+ def enabled_by_default(self) -> bool:
56
+ """Whether this rule is active by default in the engine."""
57
+ pass
58
+
59
+ @abc.abstractmethod
60
+ def evaluate(self, profile: ProfileResult) -> list[RuleFinding]:
61
+ """Evaluate this rule against a computed ProfileResult.
62
+
63
+ Args:
64
+ profile: The precomputed ProfileResult structure.
65
+
66
+ Returns:
67
+ A list of RuleFinding instances.
68
+ """
69
+ pass
@@ -0,0 +1,95 @@
1
+ """Rule for detecting categorical columns with high cardinality."""
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 HighCardinalityRule(BaseRule):
11
+ """Flags categorical columns that have an unusually high unique value ratio."""
12
+
13
+ def __init__(self, threshold: float = 0.50, min_cardinality: int = 20) -> None:
14
+ """Initialize the high cardinality rule.
15
+
16
+ Args:
17
+ threshold: Unique ratio threshold (0.0 to 1.0), defined as
18
+ unique count / non-missing count.
19
+ min_cardinality: Minimum unique value count required to flag high cardinality.
20
+
21
+ Raises:
22
+ ValueError: If threshold is not between 0.0 and 1.0.
23
+ """
24
+ if not (0.0 <= threshold <= 1.0):
25
+ raise ValueError("threshold must be a ratio between 0.0 and 1.0.")
26
+ self.threshold = threshold
27
+ self.min_cardinality = min_cardinality
28
+
29
+ @property
30
+ def id(self) -> str:
31
+ return "statistical.high_cardinality"
32
+
33
+ @property
34
+ def name(self) -> str:
35
+ return "High Cardinality"
36
+
37
+ @property
38
+ def description(self) -> str:
39
+ return "Detects categorical columns with unusually high unique value counts relative to size."
40
+
41
+ @property
42
+ def category(self) -> str:
43
+ return "statistical"
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.logical_type == "categorical":
58
+ cat_prof = profile.categorical_profiles.get(col_name)
59
+ if cat_prof is not None:
60
+ cardinality = cat_prof.cardinality
61
+ non_missing = (
62
+ profile.dataset_summary.row_count - col_prof.missing_count
63
+ )
64
+
65
+ if non_missing > 0:
66
+ ratio = cardinality / non_missing
67
+ if (
68
+ ratio > self.threshold
69
+ and cardinality >= self.min_cardinality
70
+ ):
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"High cardinality in column '{col_name}'",
79
+ description=(
80
+ f"Column '{col_name}' is categorical but has a high ratio "
81
+ f"of unique values: {cardinality} unique values out of "
82
+ f"{non_missing} non-null rows ({ratio * 100:.2f}% ratio)."
83
+ ),
84
+ evidence={
85
+ "cardinality": cardinality,
86
+ "non_missing_count": non_missing,
87
+ "unique_ratio": ratio,
88
+ "threshold": self.threshold,
89
+ "min_cardinality": self.min_cardinality,
90
+ },
91
+ confidence=1.0,
92
+ )
93
+ )
94
+
95
+ return findings