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,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
|