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,17 @@
1
+ """Typed exceptions raised by Featuresmith core components."""
2
+
3
+
4
+ class ConnectorError(Exception):
5
+ """Raised when a data source cannot be validated or loaded."""
6
+
7
+
8
+ class SourceNotFoundError(ConnectorError):
9
+ """Raised when the target source path or file does not exist."""
10
+
11
+
12
+ class UnsupportedFormatError(ConnectorError):
13
+ """Raised when the data source has an unsupported format or invalid type."""
14
+
15
+
16
+ class SourceParseError(ConnectorError):
17
+ """Raised when parsing or reading the data source fails."""
@@ -0,0 +1,412 @@
1
+ """Canonical schema for profiling results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class DatasetSummary:
12
+ """High-level summary of the dataset.
13
+
14
+ Attributes:
15
+ row_count: Number of rows in the dataset.
16
+ column_count: Number of columns in the dataset.
17
+ size_in_bytes: Approximate size in bytes if known.
18
+ missing_percentage: Overall percentage of missing cells.
19
+ duplicate_percentage: Percentage of duplicate rows in the dataset.
20
+ num_numeric_columns: Number of numeric columns.
21
+ num_categorical_columns: Number of categorical columns.
22
+ num_datetime_columns: Number of datetime columns.
23
+ num_text_columns: Number of text columns.
24
+ num_constant_columns: Number of constant columns.
25
+ num_fully_empty_columns: Number of fully empty columns.
26
+ """
27
+
28
+ row_count: int
29
+ column_count: int
30
+ size_in_bytes: int | None
31
+ missing_percentage: float
32
+ duplicate_percentage: float
33
+ num_numeric_columns: int
34
+ num_categorical_columns: int
35
+ num_datetime_columns: int
36
+ num_text_columns: int
37
+ num_constant_columns: int
38
+ num_fully_empty_columns: int
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class ColumnProfile:
43
+ """General profile summary for a single column.
44
+
45
+ Attributes:
46
+ name: Column name.
47
+ dtype: Native datatype string.
48
+ logical_type: Inferred logical type ("numeric", "categorical", "datetime", "text").
49
+ missing_count: Count of missing values in the column.
50
+ missing_percentage: Percentage of missing values.
51
+ is_constant: Whether the column is constant (contains 1 or fewer unique values).
52
+ is_fully_empty: Whether the column is fully empty (all values are missing).
53
+ """
54
+
55
+ name: str
56
+ dtype: str
57
+ logical_type: str
58
+ missing_count: int
59
+ missing_percentage: float
60
+ is_constant: bool
61
+ is_fully_empty: bool
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class NumericProfile:
66
+ """Detailed profiling statistics for a numeric column.
67
+
68
+ Attributes:
69
+ column_name: Name of the column.
70
+ count: Total non-missing count.
71
+ missing_count: Total missing count.
72
+ missing_percentage: Percentage of missing values.
73
+ unique_count: Number of unique non-missing values.
74
+ mean: Arithmetic mean.
75
+ median: 50th percentile.
76
+ mode: The most common value (first if multiple).
77
+ minimum: Minimum value.
78
+ maximum: Maximum value.
79
+ range: Maximum - minimum.
80
+ variance: Sample variance (ddof=1).
81
+ std_dev: Sample standard deviation (ddof=1).
82
+ q1: 25th percentile.
83
+ q2: 50th percentile (median).
84
+ q3: 75th percentile.
85
+ iqr: Interquartile range (q3 - q1).
86
+ sum: Sum of all values.
87
+ zero_count: Number of zeros.
88
+ negative_count: Number of negative values.
89
+ positive_count: Number of positive values.
90
+ skewness: Skewness of the distribution.
91
+ kurtosis: Kurtosis of the distribution.
92
+ """
93
+
94
+ column_name: str
95
+ count: int
96
+ missing_count: int
97
+ missing_percentage: float
98
+ unique_count: int
99
+ mean: float | None
100
+ median: float | None
101
+ mode: float | None
102
+ minimum: float | None
103
+ maximum: float | None
104
+ range: float | None
105
+ variance: float | None
106
+ std_dev: float | None
107
+ q1: float | None
108
+ q2: float | None
109
+ q3: float | None
110
+ iqr: float | None
111
+ sum: float | None
112
+ zero_count: int
113
+ negative_count: int
114
+ positive_count: int
115
+ skewness: float | None
116
+ kurtosis: float | None
117
+
118
+
119
+ @dataclass(frozen=True, slots=True)
120
+ class CategoricalProfile:
121
+ """Detailed profiling statistics for a categorical column.
122
+
123
+ Attributes:
124
+ column_name: Name of the column.
125
+ cardinality: Number of unique non-missing categories.
126
+ unique_count: Number of unique non-missing values.
127
+ missing_count: Total missing count.
128
+ frequency_table: Mapping of value string representation to count (capped to a maximum size, default 1000).
129
+ top_values: List of (value, count) pairs for top frequent items.
130
+ least_frequent_values: List of (value, count) pairs for least frequent items.
131
+ most_common_category: Name of the most frequent category.
132
+ entropy: Shannon entropy of categories (base 2).
133
+ """
134
+
135
+ column_name: str
136
+ cardinality: int
137
+ unique_count: int
138
+ missing_count: int
139
+ frequency_table: Mapping[str, int]
140
+ top_values: Sequence[tuple[str, int]]
141
+ least_frequent_values: Sequence[tuple[str, int]]
142
+ most_common_category: str | None
143
+ entropy: float | None
144
+
145
+ def __post_init__(self) -> None:
146
+ """Freeze mutable fields to improve immutability consistency."""
147
+ from types import MappingProxyType
148
+
149
+ object.__setattr__(
150
+ self, "frequency_table", MappingProxyType(dict(self.frequency_table))
151
+ )
152
+ object.__setattr__(self, "top_values", tuple(self.top_values))
153
+ object.__setattr__(
154
+ self, "least_frequent_values", tuple(self.least_frequent_values)
155
+ )
156
+
157
+
158
+ @dataclass(frozen=True, slots=True)
159
+ class DatetimeProfile:
160
+ """Detailed profiling statistics for a datetime column.
161
+
162
+ Attributes:
163
+ column_name: Name of the column.
164
+ minimum: Earliest timestamp string (ISO 8601).
165
+ maximum: Latest timestamp string (ISO 8601).
166
+ range_days: Range of timestamps in days.
167
+ missing_count: Number of missing values.
168
+ earliest_record: Same as minimum.
169
+ latest_record: Same as maximum.
170
+ """
171
+
172
+ column_name: str
173
+ minimum: str | None
174
+ maximum: str | None
175
+ range_days: float | None
176
+ missing_count: int
177
+ earliest_record: str | None
178
+ latest_record: str | None
179
+
180
+
181
+ @dataclass(frozen=True, slots=True)
182
+ class TextProfile:
183
+ """Detailed profiling statistics for a text column.
184
+
185
+ Attributes:
186
+ column_name: Name of the column.
187
+ avg_length: Average character length.
188
+ min_length: Minimum character length.
189
+ max_length: Maximum character length.
190
+ empty_strings: Number of empty string values ("").
191
+ whitespace_only: Number of strings containing only whitespace.
192
+ char_count: Total character count across all strings.
193
+ word_count: Total word count across all strings.
194
+ """
195
+
196
+ column_name: str
197
+ avg_length: float | None
198
+ min_length: int | None
199
+ max_length: int | None
200
+ empty_strings: int
201
+ whitespace_only: int
202
+ char_count: int
203
+ word_count: int
204
+
205
+
206
+ @dataclass(frozen=True, slots=True)
207
+ class MissingValueSummary:
208
+ """Summary of missingness across the dataset.
209
+
210
+ Attributes:
211
+ column_missing_counts: Mapping of column name to missing count.
212
+ column_missing_percentages: Mapping of column name to missing percentage.
213
+ total_missing: Total count of missing cells.
214
+ dataset_missing_percentage: Overall dataset-wide missing cell percentage.
215
+ """
216
+
217
+ column_missing_counts: Mapping[str, int]
218
+ column_missing_percentages: Mapping[str, float]
219
+ total_missing: int
220
+ dataset_missing_percentage: float
221
+
222
+ def __post_init__(self) -> None:
223
+ """Freeze mutable fields to improve immutability consistency."""
224
+ from types import MappingProxyType
225
+
226
+ object.__setattr__(
227
+ self,
228
+ "column_missing_counts",
229
+ MappingProxyType(dict(self.column_missing_counts)),
230
+ )
231
+ object.__setattr__(
232
+ self,
233
+ "column_missing_percentages",
234
+ MappingProxyType(dict(self.column_missing_percentages)),
235
+ )
236
+
237
+
238
+ @dataclass(frozen=True, slots=True)
239
+ class DuplicateSummary:
240
+ """Summary of duplicate records and redundant columns.
241
+
242
+ Attributes:
243
+ duplicate_rows_count: Number of duplicate rows.
244
+ duplicate_percentage: Percentage of duplicate rows.
245
+ constant_columns: List of constant column names.
246
+ fully_empty_columns: List of fully empty column names.
247
+ """
248
+
249
+ duplicate_rows_count: int
250
+ duplicate_percentage: float
251
+ constant_columns: Sequence[str]
252
+ fully_empty_columns: Sequence[str]
253
+
254
+ def __post_init__(self) -> None:
255
+ """Freeze mutable fields to improve immutability consistency."""
256
+ object.__setattr__(self, "constant_columns", tuple(self.constant_columns))
257
+ object.__setattr__(self, "fully_empty_columns", tuple(self.fully_empty_columns))
258
+
259
+
260
+ @dataclass(frozen=True, slots=True)
261
+ class CorrelationSummary:
262
+ """Summary of pairwise column correlations.
263
+
264
+ Attributes:
265
+ pearson: Mapping of colA -> colB -> Pearson correlation value.
266
+ spearman: Reserved for Spearman correlations.
267
+ kendall: Reserved for Kendall correlations.
268
+ """
269
+
270
+ pearson: Mapping[str, Mapping[str, float | None]]
271
+ spearman: Mapping[str, Mapping[str, float | None]] = field(default_factory=dict)
272
+ kendall: Mapping[str, Mapping[str, float | None]] = field(default_factory=dict)
273
+
274
+ def __post_init__(self) -> None:
275
+ """Freeze mutable fields to improve immutability consistency."""
276
+ from types import MappingProxyType
277
+
278
+ pearson_frozen = {k: MappingProxyType(dict(v)) for k, v in self.pearson.items()}
279
+ object.__setattr__(self, "pearson", MappingProxyType(pearson_frozen))
280
+
281
+ spearman_frozen = {
282
+ k: MappingProxyType(dict(v)) for k, v in self.spearman.items()
283
+ }
284
+ object.__setattr__(self, "spearman", MappingProxyType(spearman_frozen))
285
+
286
+ kendall_frozen = {k: MappingProxyType(dict(v)) for k, v in self.kendall.items()}
287
+ object.__setattr__(self, "kendall", MappingProxyType(kendall_frozen))
288
+
289
+
290
+ @dataclass(frozen=True, slots=True)
291
+ class DatasetMetadata:
292
+ """Descriptive metadata from the data source and connector.
293
+
294
+ Attributes:
295
+ source: Source file path or identifier.
296
+ file_size: Size in bytes if applicable.
297
+ backend: DataFrame backend ("polars" or "pandas").
298
+ custom_metadata: Optional dictionary of extra connector metadata.
299
+ """
300
+
301
+ source: str | None
302
+ file_size: int | None
303
+ backend: str
304
+ custom_metadata: Mapping[str, Any] = field(default_factory=dict)
305
+
306
+ def __post_init__(self) -> None:
307
+ """Freeze mutable fields to improve immutability consistency."""
308
+ from types import MappingProxyType
309
+
310
+ object.__setattr__(
311
+ self, "custom_metadata", MappingProxyType(dict(self.custom_metadata))
312
+ )
313
+
314
+
315
+ @dataclass(frozen=True, slots=True)
316
+ class ExecutionMetadata:
317
+ """Metadata about the profiling run itself.
318
+
319
+ Attributes:
320
+ start_time: ISO 8601 timestamp when profiling started.
321
+ duration_seconds: Duration of the profiling run.
322
+ featuresmith_version: Version of Featuresmith used.
323
+ """
324
+
325
+ start_time: str
326
+ duration_seconds: float
327
+ featuresmith_version: str
328
+
329
+
330
+ @dataclass(frozen=True, slots=True)
331
+ class ProfileResult:
332
+ """The canonical, strongly-typed output of the profiling engine.
333
+
334
+ Attributes:
335
+ dataset_summary: High-level dataset summary.
336
+ column_profiles: Individual column overview profiles.
337
+ numeric_profiles: Numeric profiling details.
338
+ categorical_profiles: Categorical profiling details.
339
+ datetime_profiles: Datetime profiling details.
340
+ text_profiles: Text profiling details.
341
+ missing_value_summary: Missing value summary.
342
+ duplicate_summary: Duplicate summary.
343
+ correlation_summary: Correlation matrix.
344
+ dataset_metadata: Source metadata.
345
+ execution_metadata: Run execution metadata.
346
+ """
347
+
348
+ dataset_summary: DatasetSummary
349
+ column_profiles: Mapping[str, ColumnProfile]
350
+ numeric_profiles: Mapping[str, NumericProfile]
351
+ categorical_profiles: Mapping[str, CategoricalProfile]
352
+ datetime_profiles: Mapping[str, DatetimeProfile]
353
+ text_profiles: Mapping[str, TextProfile]
354
+ missing_value_summary: MissingValueSummary
355
+ duplicate_summary: DuplicateSummary
356
+ correlation_summary: CorrelationSummary
357
+ dataset_metadata: DatasetMetadata
358
+ execution_metadata: ExecutionMetadata
359
+
360
+ def __post_init__(self) -> None:
361
+ """Freeze mutable fields to improve immutability consistency."""
362
+ from types import MappingProxyType
363
+
364
+ object.__setattr__(
365
+ self, "column_profiles", MappingProxyType(dict(self.column_profiles))
366
+ )
367
+ object.__setattr__(
368
+ self,
369
+ "numeric_profiles",
370
+ MappingProxyType(dict(self.numeric_profiles)),
371
+ )
372
+ object.__setattr__(
373
+ self,
374
+ "categorical_profiles",
375
+ MappingProxyType(dict(self.categorical_profiles)),
376
+ )
377
+ object.__setattr__(
378
+ self,
379
+ "datetime_profiles",
380
+ MappingProxyType(dict(self.datetime_profiles)),
381
+ )
382
+ object.__setattr__(
383
+ self, "text_profiles", MappingProxyType(dict(self.text_profiles))
384
+ )
385
+
386
+ def to_dict(self) -> dict[str, Any]:
387
+ """Serialize the profile result to a dictionary of primitive values.
388
+
389
+ Returns:
390
+ A dictionary representation suitable for JSON serialization.
391
+ """
392
+ from typing import cast
393
+
394
+ return cast(dict[str, Any], _asdict_custom(self))
395
+
396
+
397
+ def _asdict_custom(obj: Any) -> Any:
398
+ """Recursively convert dataclasses and mapping proxies to standard python primitives."""
399
+ import dataclasses
400
+ from types import MappingProxyType
401
+
402
+ if dataclasses.is_dataclass(obj):
403
+ return {
404
+ f.name: _asdict_custom(getattr(obj, f.name))
405
+ for f in dataclasses.fields(obj)
406
+ }
407
+ elif isinstance(obj, (dict, MappingProxyType)):
408
+ return {k: _asdict_custom(v) for k, v in obj.items()}
409
+ elif isinstance(obj, (list, tuple)):
410
+ return [_asdict_custom(v) for v in obj]
411
+ else:
412
+ return obj
@@ -0,0 +1,44 @@
1
+ """Serializable model representing a single rule engine finding."""
2
+
3
+ import uuid
4
+ from collections.abc import Mapping
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class RuleFinding:
11
+ """A single finding generated by evaluating a rule against a profile.
12
+
13
+ Attributes:
14
+ rule_id: The stable namespaced identifier of the rule.
15
+ rule_name: Human-readable name of the rule.
16
+ category: The category of the rule ("quality", "statistical", "leakage").
17
+ severity: The severity of the finding ("info", "warning", "critical").
18
+ column_name: Name of the column this finding applies to, or None if dataset-wide.
19
+ title: Short title summarizing the finding.
20
+ description: Detailed explanation of the finding.
21
+ evidence: Dictionary of metrics or values that triggered the finding.
22
+ confidence: Confidence level in the finding (float between 0.0 and 1.0).
23
+ id: Unique identifier for this finding instance, defaults to a random UUID.
24
+ metadata: Optional dictionary of additional metadata.
25
+ """
26
+
27
+ rule_id: str
28
+ rule_name: str
29
+ category: str
30
+ severity: str
31
+ column_name: str | None
32
+ title: str
33
+ description: str
34
+ evidence: Mapping[str, Any]
35
+ confidence: float = 1.0
36
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
37
+ metadata: Mapping[str, Any] = field(default_factory=dict)
38
+
39
+ def __post_init__(self) -> None:
40
+ """Freeze mutable fields to improve immutability consistency."""
41
+ from types import MappingProxyType
42
+
43
+ object.__setattr__(self, "evidence", MappingProxyType(dict(self.evidence)))
44
+ object.__setattr__(self, "metadata", MappingProxyType(dict(self.metadata)))
@@ -0,0 +1,49 @@
1
+ """Serializable model representing the result of a rule engine run."""
2
+
3
+ from collections.abc import Mapping, Sequence
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+ from featuresmith.core.profile_result import ProfileResult
8
+ from featuresmith.core.rule_finding import RuleFinding
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class RuleResult:
13
+ """The canonical output of the Featuresmith Rule Engine.
14
+
15
+ Attributes:
16
+ profile: The ProfileResult used for the evaluation.
17
+ findings: The list of rule findings generated.
18
+ executed_rules: List of rule IDs that were successfully executed.
19
+ execution_time_ms: Total time taken to run the rules in milliseconds.
20
+ failed_rules: Mapping from rule ID to the exception message/traceback for any rules that failed.
21
+ """
22
+
23
+ profile: ProfileResult
24
+ findings: Sequence[RuleFinding]
25
+ executed_rules: Sequence[str]
26
+ execution_time_ms: float
27
+ failed_rules: Mapping[str, str] = field(default_factory=dict)
28
+
29
+ def __post_init__(self) -> None:
30
+ """Freeze mutable fields to improve immutability consistency."""
31
+ from types import MappingProxyType
32
+
33
+ object.__setattr__(self, "findings", tuple(self.findings))
34
+ object.__setattr__(self, "executed_rules", tuple(self.executed_rules))
35
+ object.__setattr__(
36
+ self, "failed_rules", MappingProxyType(dict(self.failed_rules))
37
+ )
38
+
39
+ def to_dict(self) -> dict[str, Any]:
40
+ """Serialize the rule result to a dictionary of primitive values.
41
+
42
+ Returns:
43
+ A dictionary representation suitable for JSON serialization.
44
+ """
45
+ from typing import cast
46
+
47
+ from featuresmith.core.profile_result import _asdict_custom
48
+
49
+ return cast(dict[str, Any], _asdict_custom(self))
@@ -0,0 +1,34 @@
1
+ """Typed schemas shared by core pipeline stages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class ColumnSchema:
10
+ """Describe one column in a normalized dataset.
11
+
12
+ Attributes:
13
+ name: The column name as exposed by the dataframe backend.
14
+ dtype: The backend's stable string representation of the column dtype.
15
+ """
16
+
17
+ name: str
18
+ dtype: str
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class DatasetSchema:
23
+ """Describe the columns available in a normalized dataset.
24
+
25
+ Attributes:
26
+ columns: Ordered column descriptors from the source dataframe.
27
+ """
28
+
29
+ columns: tuple[ColumnSchema, ...]
30
+
31
+ @property
32
+ def names(self) -> tuple[str, ...]:
33
+ """Return the ordered column names."""
34
+ return tuple(column.name for column in self.columns)
@@ -0,0 +1,5 @@
1
+ """Profiling package for Featuresmith."""
2
+
3
+ from featuresmith.profiling.profiler import profile_dataset
4
+
5
+ __all__ = ["profile_dataset"]
@@ -0,0 +1,108 @@
1
+ """Categorical column profiling module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ import polars as pl
8
+
9
+ from featuresmith.core.dataset import Dataset
10
+ from featuresmith.core.profile_result import CategoricalProfile
11
+
12
+
13
+ def profile_categorical_column(
14
+ dataset: Dataset, col_name: str, max_frequency_table_size: int = 1000
15
+ ) -> CategoricalProfile:
16
+ """Profile a single categorical column.
17
+
18
+ Args:
19
+ dataset: The normalized dataset.
20
+ col_name: Name of the column.
21
+ max_frequency_table_size: Maximum entries to keep in frequency_table (default 1000).
22
+
23
+ Returns:
24
+ A CategoricalProfile object.
25
+ """
26
+ df = dataset.dataframe
27
+
28
+ # Calculate missing count first
29
+ if dataset.backend == "pandas":
30
+ series = df[col_name]
31
+ missing_count = int(series.isna().sum())
32
+ else:
33
+ # Polars
34
+ missing_count = int(df.select(pl.col(col_name).null_count())[0, 0])
35
+
36
+ # Get non-null counts as frequency table
37
+ frequency_table: dict[str, int] = {}
38
+ if dataset.backend == "pandas":
39
+ series = df[col_name].dropna()
40
+ if len(series) > 0:
41
+ # Group and count
42
+ vc = series.astype(str).value_counts(dropna=True)
43
+ frequency_table = {str(k): int(v) for k, v in vc.to_dict().items()}
44
+ else:
45
+ # Polars
46
+ non_null_df = df.select(pl.col(col_name).drop_nulls().cast(pl.String))
47
+ if non_null_df.height > 0:
48
+ # Group by and count
49
+ freq_df = (
50
+ non_null_df.group_by(col_name)
51
+ .len()
52
+ .sort(by=["len", col_name], descending=[True, False])
53
+ )
54
+ categories = freq_df.get_column(col_name).to_list()
55
+ counts = freq_df.get_column("len").to_list()
56
+ frequency_table = {
57
+ str(k): int(v) for k, v in zip(categories, counts, strict=True)
58
+ }
59
+
60
+ cardinality = len(frequency_table)
61
+ unique_count = cardinality
62
+
63
+ if cardinality == 0:
64
+ return CategoricalProfile(
65
+ column_name=col_name,
66
+ cardinality=0,
67
+ unique_count=0,
68
+ missing_count=missing_count,
69
+ frequency_table={},
70
+ top_values=[],
71
+ least_frequent_values=[],
72
+ most_common_category=None,
73
+ entropy=None,
74
+ )
75
+
76
+ # Deterministic sorting: count descending, key/value string ascending
77
+ sorted_items = sorted(frequency_table.items(), key=lambda x: (-x[1], x[0]))
78
+ top_values = sorted_items[:10]
79
+
80
+ # Deterministic sorting for least frequent: count ascending, key/value string ascending
81
+ sorted_least = sorted(frequency_table.items(), key=lambda x: (x[1], x[0]))
82
+ least_frequent_values = sorted_least[:10]
83
+
84
+ most_common_category = top_values[0][0] if top_values else None
85
+
86
+ # Entropy (Shannon entropy with base 2)
87
+ total_non_null = sum(frequency_table.values())
88
+ entropy = 0.0
89
+ for count in frequency_table.values():
90
+ p = count / total_non_null
91
+ if p > 0:
92
+ entropy -= p * math.log2(p)
93
+
94
+ # Cap frequency table size *after* computing cardinality, top/least values, and entropy
95
+ capped_items = sorted_items[:max_frequency_table_size]
96
+ frequency_table = dict(capped_items)
97
+
98
+ return CategoricalProfile(
99
+ column_name=col_name,
100
+ cardinality=cardinality,
101
+ unique_count=unique_count,
102
+ missing_count=missing_count,
103
+ frequency_table=frequency_table,
104
+ top_values=top_values,
105
+ least_frequent_values=least_frequent_values,
106
+ most_common_category=most_common_category,
107
+ entropy=entropy,
108
+ )