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,67 @@
1
+ """Correlation summary module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ import pandas as pd
8
+ import polars as pl
9
+
10
+ from featuresmith.core.dataset import Dataset
11
+ from featuresmith.core.profile_result import CorrelationSummary
12
+
13
+
14
+ def compute_correlations(
15
+ dataset: Dataset,
16
+ numeric_columns: list[str],
17
+ max_correlation_columns: int = 100,
18
+ ) -> CorrelationSummary:
19
+ """Compute pairwise Pearson correlation matrix for numeric columns.
20
+
21
+ Args:
22
+ dataset: The normalized dataset.
23
+ numeric_columns: List of numeric column names.
24
+ max_correlation_columns: Maximum number of numeric columns to correlate.
25
+
26
+ Returns:
27
+ A CorrelationSummary object.
28
+ """
29
+ if not numeric_columns:
30
+ return CorrelationSummary(pearson={})
31
+
32
+ # Cap columns to prevent combinatorial blowup
33
+ cols = numeric_columns[:max_correlation_columns]
34
+ df = dataset.dataframe
35
+
36
+ pearson: dict[str, dict[str, float | None]] = {c: {} for c in cols}
37
+
38
+ if dataset.backend == "pandas":
39
+ # Pandas
40
+ corr_df = df[cols].corr(method="pearson")
41
+ for col1 in cols:
42
+ for col2 in cols:
43
+ val = corr_df.loc[col1, col2]
44
+ if pd.isna(val) or math.isnan(val) or math.isinf(val):
45
+ pearson[col1][col2] = None
46
+ else:
47
+ pearson[col1][col2] = float(val)
48
+ else:
49
+ # Polars: compute correlations in parallel using expressions
50
+ exprs = []
51
+ for i, c1 in enumerate(cols):
52
+ for c2 in cols[i:]:
53
+ exprs.append(pl.corr(c1, c2).alias(f"{c1}__{c2}"))
54
+
55
+ if exprs:
56
+ res = df.select(exprs)
57
+ for i, c1 in enumerate(cols):
58
+ for c2 in cols[i:]:
59
+ val = res.get_column(f"{c1}__{c2}")[0]
60
+ if val is None or math.isnan(val) or math.isinf(val):
61
+ f_val = None
62
+ else:
63
+ f_val = float(val)
64
+ pearson[c1][c2] = f_val
65
+ pearson[c2][c1] = f_val
66
+
67
+ return CorrelationSummary(pearson=pearson)
@@ -0,0 +1,105 @@
1
+ """Datetime column profiling module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date, datetime
6
+
7
+ import pandas as pd
8
+ import polars as pl
9
+
10
+ from featuresmith.core.dataset import Dataset
11
+ from featuresmith.core.profile_result import DatetimeProfile
12
+
13
+
14
+ def profile_datetime_column(dataset: Dataset, col_name: str) -> DatetimeProfile:
15
+ """Profile a single datetime column.
16
+
17
+ Args:
18
+ dataset: The normalized dataset.
19
+ col_name: Name of the column.
20
+
21
+ Returns:
22
+ A DatetimeProfile object.
23
+ """
24
+ df = dataset.dataframe
25
+
26
+ if dataset.backend == "pandas":
27
+ series = df[col_name]
28
+ missing_count = int(series.isna().sum())
29
+
30
+ min_val = series.min()
31
+ max_val = series.max()
32
+
33
+ # Check if min_val/max_val are null (NaT)
34
+ if pd.isna(min_val) or pd.isna(max_val):
35
+ return DatetimeProfile(
36
+ column_name=col_name,
37
+ minimum=None,
38
+ maximum=None,
39
+ range_days=None,
40
+ missing_count=missing_count,
41
+ earliest_record=None,
42
+ latest_record=None,
43
+ )
44
+
45
+ # Handle conversion to standard datetime if they are Timestamp objects
46
+ if hasattr(min_val, "to_pydatetime"):
47
+ min_dt = min_val.to_pydatetime()
48
+ else:
49
+ min_dt = min_val
50
+
51
+ if hasattr(max_val, "to_pydatetime"):
52
+ max_dt = max_val.to_pydatetime()
53
+ else:
54
+ max_dt = max_val
55
+
56
+ # If min_dt/max_dt are date (not datetime) objects, convert them to datetimes to subtract safely
57
+ if isinstance(min_dt, date) and not isinstance(min_dt, datetime):
58
+ min_dt = datetime.combine(min_dt, datetime.min.time())
59
+ if isinstance(max_dt, date) and not isinstance(max_dt, datetime):
60
+ max_dt = datetime.combine(max_dt, datetime.min.time())
61
+
62
+ # ISO format
63
+ minimum = min_val.isoformat()
64
+ maximum = max_val.isoformat()
65
+ range_days = float((max_dt - min_dt).total_seconds() / 86400.0)
66
+
67
+ else:
68
+ # Polars
69
+ missing_count = int(df.select(pl.col(col_name).null_count())[0, 0])
70
+ min_val = df.select(pl.col(col_name).min())[0, 0]
71
+ max_val = df.select(pl.col(col_name).max())[0, 0]
72
+
73
+ if min_val is None or max_val is None:
74
+ return DatetimeProfile(
75
+ column_name=col_name,
76
+ minimum=None,
77
+ maximum=None,
78
+ range_days=None,
79
+ missing_count=missing_count,
80
+ earliest_record=None,
81
+ latest_record=None,
82
+ )
83
+
84
+ # Polars returns datetime.date or datetime.datetime objects directly
85
+ min_dt = min_val
86
+ max_dt = max_val
87
+
88
+ if isinstance(min_dt, date) and not isinstance(min_dt, datetime):
89
+ min_dt = datetime.combine(min_dt, datetime.min.time())
90
+ if isinstance(max_dt, date) and not isinstance(max_dt, datetime):
91
+ max_dt = datetime.combine(max_dt, datetime.min.time())
92
+
93
+ minimum = min_val.isoformat()
94
+ maximum = max_val.isoformat()
95
+ range_days = float((max_dt - min_dt).total_seconds() / 86400.0)
96
+
97
+ return DatetimeProfile(
98
+ column_name=col_name,
99
+ minimum=minimum,
100
+ maximum=maximum,
101
+ range_days=range_days,
102
+ missing_count=missing_count,
103
+ earliest_record=minimum,
104
+ latest_record=maximum,
105
+ )
@@ -0,0 +1,48 @@
1
+ """Duplicate analysis module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from featuresmith.core.dataset import Dataset
6
+ from featuresmith.core.profile_result import DuplicateSummary
7
+
8
+
9
+ def analyze_duplicates(
10
+ dataset: Dataset, constant_columns: list[str], fully_empty_columns: list[str]
11
+ ) -> DuplicateSummary:
12
+ """Analyze duplicate rows in the dataset.
13
+
14
+ Args:
15
+ dataset: The normalized dataset.
16
+ constant_columns: Pre-identified constant columns.
17
+ fully_empty_columns: Pre-identified fully empty columns.
18
+
19
+ Returns:
20
+ A DuplicateSummary object.
21
+ """
22
+ df = dataset.dataframe
23
+ row_count = dataset.row_count
24
+
25
+ if row_count == 0:
26
+ return DuplicateSummary(
27
+ duplicate_rows_count=0,
28
+ duplicate_percentage=0.0,
29
+ constant_columns=constant_columns,
30
+ fully_empty_columns=fully_empty_columns,
31
+ )
32
+
33
+ if dataset.backend == "pandas":
34
+ duplicate_rows_count = int(df.duplicated().sum())
35
+ else:
36
+ # Polars: duplicate rows count is total rows minus unique rows
37
+ # This is equivalent to pandas duplicated().sum()
38
+ unique_rows_count = df.unique().height
39
+ duplicate_rows_count = row_count - unique_rows_count
40
+
41
+ duplicate_percentage = float((duplicate_rows_count / row_count) * 100.0)
42
+
43
+ return DuplicateSummary(
44
+ duplicate_rows_count=duplicate_rows_count,
45
+ duplicate_percentage=duplicate_percentage,
46
+ constant_columns=list(constant_columns),
47
+ fully_empty_columns=list(fully_empty_columns),
48
+ )
@@ -0,0 +1,70 @@
1
+ """Missing value analysis 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 MissingValueSummary
9
+
10
+
11
+ def analyze_missing_values(dataset: Dataset) -> MissingValueSummary:
12
+ """Analyze missing values in the dataset.
13
+
14
+ Args:
15
+ dataset: The normalized dataset.
16
+
17
+ Returns:
18
+ A MissingValueSummary object.
19
+ """
20
+ df = dataset.dataframe
21
+ row_count = dataset.row_count
22
+ col_count = dataset.column_count
23
+ total_cells = row_count * col_count
24
+
25
+ column_missing_counts: dict[str, int] = {}
26
+ column_missing_percentages: dict[str, float] = {}
27
+ total_missing = 0
28
+
29
+ if col_count == 0 or row_count == 0:
30
+ # Edge case: empty dataset
31
+ for col_name in dataset.schema.names:
32
+ column_missing_counts[col_name] = row_count
33
+ column_missing_percentages[col_name] = 100.0 if row_count > 0 else 0.0
34
+ return MissingValueSummary(
35
+ column_missing_counts=column_missing_counts,
36
+ column_missing_percentages=column_missing_percentages,
37
+ total_missing=row_count * col_count,
38
+ dataset_missing_percentage=100.0 if total_cells > 0 else 0.0,
39
+ )
40
+
41
+ if dataset.backend == "pandas":
42
+ # Compute missing counts for all columns at once in pandas
43
+ missing_series = df.isna().sum()
44
+ for col_name in dataset.schema.names:
45
+ count = int(missing_series[col_name])
46
+ column_missing_counts[col_name] = count
47
+ column_missing_percentages[col_name] = float((count / row_count) * 100.0)
48
+ total_missing += count
49
+ else:
50
+ # Polars
51
+ # Compute null counts for all columns in a single select
52
+ null_counts = df.select(
53
+ [pl.col(col).null_count().alias(col) for col in dataset.schema.names]
54
+ )
55
+ for col_name in dataset.schema.names:
56
+ count = int(null_counts.get_column(col_name)[0])
57
+ column_missing_counts[col_name] = count
58
+ column_missing_percentages[col_name] = float((count / row_count) * 100.0)
59
+ total_missing += count
60
+
61
+ dataset_missing_percentage = (
62
+ float((total_missing / total_cells) * 100.0) if total_cells > 0 else 0.0
63
+ )
64
+
65
+ return MissingValueSummary(
66
+ column_missing_counts=column_missing_counts,
67
+ column_missing_percentages=column_missing_percentages,
68
+ total_missing=total_missing,
69
+ dataset_missing_percentage=dataset_missing_percentage,
70
+ )
@@ -0,0 +1,248 @@
1
+ """Numeric column profiling module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import Any
7
+
8
+ import polars as pl
9
+
10
+ from featuresmith.core.dataset import Dataset
11
+ from featuresmith.core.profile_result import NumericProfile
12
+
13
+
14
+ def _to_float(val: Any) -> float | None:
15
+ """Safely convert a value to a Python float, returning None if NaN/Inf."""
16
+ if val is None:
17
+ return None
18
+ # Check for pandas/numpy nulls
19
+ if isinstance(val, (float, int)) and (math.isnan(val) or math.isinf(val)):
20
+ return None
21
+ try:
22
+ # If it's a series or list, we grab first element
23
+ if hasattr(val, "item"):
24
+ val = val.item()
25
+ elif hasattr(val, "iloc"):
26
+ val = val.iloc[0]
27
+
28
+ f = float(val)
29
+ if math.isnan(f) or math.isinf(f):
30
+ return None
31
+ return f
32
+ except (ValueError, TypeError):
33
+ return None
34
+
35
+
36
+ def _to_int(val: Any) -> int:
37
+ """Convert a value to a Python int."""
38
+ if val is None:
39
+ return 0
40
+ try:
41
+ if hasattr(val, "item"):
42
+ val = val.item()
43
+ return int(val)
44
+ except (ValueError, TypeError):
45
+ return 0
46
+
47
+
48
+ def profile_numeric_column(dataset: Dataset, col_name: str) -> NumericProfile:
49
+ """Profile a single numeric column.
50
+
51
+ Args:
52
+ dataset: The normalized dataset.
53
+ col_name: Name of the column.
54
+
55
+ Returns:
56
+ A NumericProfile object.
57
+ """
58
+ df = dataset.dataframe
59
+ row_count = dataset.row_count
60
+
61
+ if dataset.backend == "pandas":
62
+ series = df[col_name]
63
+ non_null_series = series.dropna()
64
+ count = int(non_null_series.count())
65
+ missing_count = int(series.isna().sum())
66
+ missing_percentage = (
67
+ float((missing_count / row_count) * 100.0) if row_count > 0 else 0.0
68
+ )
69
+ unique_count = int(non_null_series.nunique())
70
+
71
+ if count == 0:
72
+ return NumericProfile(
73
+ column_name=col_name,
74
+ count=0,
75
+ missing_count=missing_count,
76
+ missing_percentage=missing_percentage,
77
+ unique_count=0,
78
+ mean=None,
79
+ median=None,
80
+ mode=None,
81
+ minimum=None,
82
+ maximum=None,
83
+ range=None,
84
+ variance=None,
85
+ std_dev=None,
86
+ q1=None,
87
+ q2=None,
88
+ q3=None,
89
+ iqr=None,
90
+ sum=None,
91
+ zero_count=0,
92
+ negative_count=0,
93
+ positive_count=0,
94
+ skewness=None,
95
+ kurtosis=None,
96
+ )
97
+
98
+ mean = _to_float(non_null_series.mean())
99
+ median = _to_float(non_null_series.median())
100
+ minimum = _to_float(non_null_series.min())
101
+ maximum = _to_float(non_null_series.max())
102
+
103
+ rng = None
104
+ if minimum is not None and maximum is not None:
105
+ rng = maximum - minimum
106
+
107
+ variance = _to_float(non_null_series.var(ddof=1))
108
+ std_dev = _to_float(non_null_series.std(ddof=1))
109
+
110
+ q1 = _to_float(non_null_series.quantile(0.25))
111
+ q2 = _to_float(non_null_series.quantile(0.50))
112
+ q3 = _to_float(non_null_series.quantile(0.75))
113
+
114
+ iqr = None
115
+ if q1 is not None and q3 is not None:
116
+ iqr = q3 - q1
117
+
118
+ sm = _to_float(non_null_series.sum())
119
+ zero_count = int((non_null_series == 0).sum())
120
+ negative_count = int((non_null_series < 0).sum())
121
+ positive_count = int((non_null_series > 0).sum())
122
+
123
+ # Mode
124
+ modes = non_null_series.mode()
125
+ mode = _to_float(modes.iloc[0]) if not modes.empty else None
126
+
127
+ skewness = _to_float(non_null_series.skew())
128
+ kurtosis = _to_float(non_null_series.kurt())
129
+
130
+ else:
131
+ # Polars
132
+ col = pl.col(col_name)
133
+ non_null_col = col.drop_nulls()
134
+
135
+ # Compute everything in a single select statement
136
+ res = df.select(
137
+ [
138
+ col.len().alias("total_len"),
139
+ col.null_count().alias("missing_count"),
140
+ non_null_col.n_unique().alias("unique_count"),
141
+ non_null_col.mean().alias("mean"),
142
+ non_null_col.median().alias("median"),
143
+ non_null_col.min().alias("min"),
144
+ non_null_col.max().alias("max"),
145
+ non_null_col.var(ddof=1).alias("var"),
146
+ non_null_col.std(ddof=1).alias("std"),
147
+ non_null_col.quantile(0.25).alias("q1"),
148
+ non_null_col.quantile(0.50).alias("q2"),
149
+ non_null_col.quantile(0.75).alias("q3"),
150
+ non_null_col.sum().alias("sum"),
151
+ (non_null_col == 0).sum().alias("zero_count"),
152
+ (non_null_col < 0).sum().alias("negative_count"),
153
+ (non_null_col > 0).sum().alias("positive_count"),
154
+ non_null_col.skew().alias("skew"),
155
+ non_null_col.kurtosis().alias("kurt"),
156
+ non_null_col.mode().sort().first().alias("mode"),
157
+ ]
158
+ )
159
+
160
+ total_len = _to_int(res.get_column("total_len")[0])
161
+ missing_count = _to_int(res.get_column("missing_count")[0])
162
+ missing_percentage = (
163
+ float((missing_count / total_len) * 100.0) if total_len > 0 else 0.0
164
+ )
165
+ unique_count = _to_int(res.get_column("unique_count")[0])
166
+ count = total_len - missing_count
167
+
168
+ if count == 0:
169
+ return NumericProfile(
170
+ column_name=col_name,
171
+ count=0,
172
+ missing_count=missing_count,
173
+ missing_percentage=missing_percentage,
174
+ unique_count=0,
175
+ mean=None,
176
+ median=None,
177
+ mode=None,
178
+ minimum=None,
179
+ maximum=None,
180
+ range=None,
181
+ variance=None,
182
+ std_dev=None,
183
+ q1=None,
184
+ q2=None,
185
+ q3=None,
186
+ iqr=None,
187
+ sum=None,
188
+ zero_count=0,
189
+ negative_count=0,
190
+ positive_count=0,
191
+ skewness=None,
192
+ kurtosis=None,
193
+ )
194
+
195
+ mean = _to_float(res.get_column("mean")[0])
196
+ median = _to_float(res.get_column("median")[0])
197
+ minimum = _to_float(res.get_column("min")[0])
198
+ maximum = _to_float(res.get_column("max")[0])
199
+
200
+ rng = None
201
+ if minimum is not None and maximum is not None:
202
+ rng = maximum - minimum
203
+
204
+ variance = _to_float(res.get_column("var")[0])
205
+ std_dev = _to_float(res.get_column("std")[0])
206
+
207
+ q1 = _to_float(res.get_column("q1")[0])
208
+ q2 = _to_float(res.get_column("q2")[0])
209
+ q3 = _to_float(res.get_column("q3")[0])
210
+
211
+ iqr = None
212
+ if q1 is not None and q3 is not None:
213
+ iqr = q3 - q1
214
+
215
+ sm = _to_float(res.get_column("sum")[0])
216
+ zero_count = _to_int(res.get_column("zero_count")[0])
217
+ negative_count = _to_int(res.get_column("negative_count")[0])
218
+ positive_count = _to_int(res.get_column("positive_count")[0])
219
+
220
+ mode = _to_float(res.get_column("mode")[0])
221
+ skewness = _to_float(res.get_column("skew")[0])
222
+ kurtosis = _to_float(res.get_column("kurt")[0])
223
+
224
+ return NumericProfile(
225
+ column_name=col_name,
226
+ count=count,
227
+ missing_count=missing_count,
228
+ missing_percentage=missing_percentage,
229
+ unique_count=unique_count,
230
+ mean=mean,
231
+ median=median,
232
+ mode=mode,
233
+ minimum=minimum,
234
+ maximum=maximum,
235
+ range=rng,
236
+ variance=variance,
237
+ std_dev=std_dev,
238
+ q1=q1,
239
+ q2=q2,
240
+ q3=q3,
241
+ iqr=iqr,
242
+ sum=sm,
243
+ zero_count=zero_count,
244
+ negative_count=negative_count,
245
+ positive_count=positive_count,
246
+ skewness=skewness,
247
+ kurtosis=kurtosis,
248
+ )