edaprep 0.2.2__tar.gz → 0.3.0__tar.gz

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 (64) hide show
  1. {edaprep-0.2.2/src/edaprep.egg-info → edaprep-0.3.0}/PKG-INFO +1 -1
  2. edaprep-0.3.0/src/edaprep/_version.py +1 -0
  3. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/config.py +44 -4
  4. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/eda/categorical.py +2 -1
  5. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/planning/rules.py +5 -0
  6. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/preprocessing/casting.py +1 -1
  7. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/preprocessing/datetime_features.py +7 -7
  8. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/preprocessing/encoding.py +13 -1
  9. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/preprocessing/missing.py +182 -9
  10. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/preprocessing/text.py +9 -3
  11. {edaprep-0.2.2 → edaprep-0.3.0/src/edaprep.egg-info}/PKG-INFO +1 -1
  12. {edaprep-0.2.2 → edaprep-0.3.0}/tests/test_eda.py +26 -0
  13. {edaprep-0.2.2 → edaprep-0.3.0}/tests/test_leakage.py +38 -0
  14. {edaprep-0.2.2 → edaprep-0.3.0}/tests/test_pipeline.py +52 -0
  15. {edaprep-0.2.2 → edaprep-0.3.0}/tests/test_transformers.py +280 -0
  16. edaprep-0.2.2/src/edaprep/_version.py +0 -1
  17. {edaprep-0.2.2 → edaprep-0.3.0}/LICENSE +0 -0
  18. {edaprep-0.2.2 → edaprep-0.3.0}/README.md +0 -0
  19. {edaprep-0.2.2 → edaprep-0.3.0}/pyproject.toml +0 -0
  20. {edaprep-0.2.2 → edaprep-0.3.0}/setup.cfg +0 -0
  21. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/__init__.py +0 -0
  22. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/backends/__init__.py +0 -0
  23. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/backends/base.py +0 -0
  24. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/backends/pandas_backend.py +0 -0
  25. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/core/__init__.py +0 -0
  26. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/core/base.py +0 -0
  27. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/core/context.py +0 -0
  28. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/core/journal.py +0 -0
  29. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/core/pipeline.py +0 -0
  30. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/eda/__init__.py +0 -0
  31. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/eda/analyzer.py +0 -0
  32. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/eda/correlation.py +0 -0
  33. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/eda/numerical.py +0 -0
  34. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/eda/outliers.py +0 -0
  35. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/eda/target.py +0 -0
  36. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/exceptions.py +0 -0
  37. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/planning/__init__.py +0 -0
  38. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/planning/decisions.py +0 -0
  39. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/planning/planner.py +0 -0
  40. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/preprocessing/__init__.py +0 -0
  41. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/preprocessing/duplicates.py +0 -0
  42. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/preprocessing/outliers.py +0 -0
  43. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/preprocessing/scaling.py +0 -0
  44. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/preprocessing/selection.py +0 -0
  45. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/preprocessing/transformations.py +0 -0
  46. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/profiling/__init__.py +0 -0
  47. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/profiling/column_types.py +0 -0
  48. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/profiling/profiler.py +0 -0
  49. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/profiling/quality.py +0 -0
  50. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/profiling/statistics.py +0 -0
  51. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/py.typed +0 -0
  52. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/reporting/__init__.py +0 -0
  53. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/reporting/html.py +0 -0
  54. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/reporting/report.py +0 -0
  55. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/types.py +0 -0
  56. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/visualization/__init__.py +0 -0
  57. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/visualization/plots.py +0 -0
  58. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep.egg-info/SOURCES.txt +0 -0
  59. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep.egg-info/dependency_links.txt +0 -0
  60. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep.egg-info/requires.txt +0 -0
  61. {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep.egg-info/top_level.txt +0 -0
  62. {edaprep-0.2.2 → edaprep-0.3.0}/tests/test_column_types.py +0 -0
  63. {edaprep-0.2.2 → edaprep-0.3.0}/tests/test_profiler.py +0 -0
  64. {edaprep-0.2.2 → edaprep-0.3.0}/tests/test_statistics.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: edaprep
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Transparent, leakage-safe EDA and ML preprocessing with an explainable planner.
5
5
  Author-email: bijay <bijaybeezoe@gmail.com>
6
6
  License: MIT
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
@@ -36,7 +36,19 @@ __all__ = ["Config", "ColumnConfig", "Thresholds", "AUTO"]
36
36
  AUTO = "auto"
37
37
 
38
38
  _MISSING_STRATEGIES = frozenset(
39
- {AUTO, "mean", "median", "mode", "constant", "ffill", "bfill", "drop_rows", "none"}
39
+ {
40
+ AUTO,
41
+ "mean",
42
+ "median",
43
+ "mode",
44
+ "constant",
45
+ "ffill",
46
+ "bfill",
47
+ "drop_rows",
48
+ "none",
49
+ "knn",
50
+ "iterative",
51
+ }
40
52
  )
41
53
  _OUTLIER_METHODS = frozenset({AUTO, "iqr", "zscore", "modified_zscore", "percentile", "none"})
42
54
  _OUTLIER_STRATEGIES = frozenset(
@@ -383,7 +395,12 @@ class Config:
383
395
  """Return the override record for ``name`` without creating one."""
384
396
  return self.columns.get(name)
385
397
 
386
- def set_columns(self, overrides: Mapping[str, Mapping[str, Any]]) -> "Config":
398
+ def set_columns(
399
+ self,
400
+ overrides: Mapping[str, Mapping[str, Any]],
401
+ *,
402
+ warn_on_unknown: bool = False,
403
+ ) -> "Config":
387
404
  """Bulk-apply overrides: ``{"age": {"imputation": "median"}, ...}``."""
388
405
  for name, kwargs in overrides.items():
389
406
  col = self.column(name)
@@ -392,6 +409,15 @@ class Config:
392
409
  valid = [
393
410
  f.name for f in dataclasses.fields(ColumnConfig) if f.name != "name"
394
411
  ]
412
+ if warn_on_unknown:
413
+ warnings.warn(
414
+ f"Config.from_dict ignoring unrecognised column({name!r}) "
415
+ f"setting: {key!r}. It was either retired in a later "
416
+ f"version of edaprep or is misspelt.",
417
+ UserWarning,
418
+ stacklevel=2,
419
+ )
420
+ continue
395
421
  raise ConfigurationError.unknown_option(
396
422
  f"column({name!r}) setting", key, valid
397
423
  )
@@ -484,7 +510,21 @@ class Config:
484
510
  equally likely to be a typo, and this is the only signal you would get.
485
511
  """
486
512
  data = dict(data)
487
- thresholds = Thresholds(**data.pop("thresholds", {}))
513
+ thresholds_raw = dict(data.pop("thresholds", {}))
514
+ known_thresholds = {f.name for f in dataclasses.fields(Thresholds)}
515
+ unknown_thresholds = sorted(set(thresholds_raw) - known_thresholds)
516
+ if unknown_thresholds:
517
+ warnings.warn(
518
+ f"Config.from_dict ignoring {len(unknown_thresholds)} unrecognised "
519
+ f"threshold setting(s): "
520
+ f"{', '.join(repr(k) for k in unknown_thresholds)}. They were either "
521
+ f"retired in a later version of edaprep or are misspelt; the rest of "
522
+ f"the threshold configuration was applied unchanged.",
523
+ UserWarning,
524
+ stacklevel=2,
525
+ )
526
+ thresholds_raw = {k: v for k, v in thresholds_raw.items() if k in known_thresholds}
527
+ thresholds = Thresholds(**thresholds_raw)
488
528
  columns_raw = data.pop("columns", {})
489
529
 
490
530
  known = {f.name for f in dataclasses.fields(cls)}
@@ -504,7 +544,7 @@ class Config:
504
544
  for name, kwargs in columns_raw.items():
505
545
  kwargs = dict(kwargs)
506
546
  kwargs.pop("name", None)
507
- cfg.set_columns({name: kwargs})
547
+ cfg.set_columns({name: kwargs}, warn_on_unknown=True)
508
548
  return cfg
509
549
 
510
550
  def __repr__(self) -> str:
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  from typing import List, Optional
11
11
 
12
+ import numpy as np
12
13
  import pandas as pd
13
14
 
14
15
  from ..config import Config
@@ -26,7 +27,7 @@ def categorical_summary(
26
27
  rare_threshold = config.effective_rare_threshold
27
28
  high_cardinality = config.effective_high_cardinality
28
29
  n_rows = profile.n_rows
29
- floor = max(1, int(rare_threshold * n_rows))
30
+ floor = max(1, int(np.ceil(rare_threshold * n_rows)))
30
31
 
31
32
  rows: List[dict] = []
32
33
  for name in profile.column_order:
@@ -452,6 +452,11 @@ def _rule_impute(cp: ColumnProfile, ctx: RuleContext) -> Optional[Decision]:
452
452
  f"{cast_missing} placeholder value(s) become NaN when the column is cast, "
453
453
  f"so it needs imputation despite reporting 0.0% missing"
454
454
  )
455
+ elif outlier_may_impute:
456
+ found = (
457
+ "0.0% missing today, but outlier_strategy='impute' may introduce NaN at the "
458
+ "OUTLIERS stage that runs before this one"
459
+ )
455
460
  else:
456
461
  found = f"{_pct(cp.missing_fraction)} missing"
457
462
 
@@ -146,7 +146,7 @@ class DataTypeInference(Transformer, ColumnTransformerMixin):
146
146
  stripped = series.astype(object).map(
147
147
  lambda v: v.strip() if isinstance(v, str) else v
148
148
  )
149
- counts["stripped"] = int((stripped != series).sum())
149
+ counts["stripped"] = int(((stripped != series) & series.notna()).sum())
150
150
  series = stripped
151
151
 
152
152
  if "sentinels_to_nan" in actions:
@@ -43,13 +43,13 @@ _EXTRACTORS: Dict[str, Callable[[pd.Series], pd.Series]] = {
43
43
  "hour": lambda s: s.dt.hour,
44
44
  "minute": lambda s: s.dt.minute,
45
45
  "second": lambda s: s.dt.second,
46
- "is_weekend": lambda s: (s.dt.dayofweek >= 5).astype("float64"),
47
- "is_month_start": lambda s: s.dt.is_month_start.astype("float64"),
48
- "is_month_end": lambda s: s.dt.is_month_end.astype("float64"),
49
- "is_quarter_start": lambda s: s.dt.is_quarter_start.astype("float64"),
50
- "is_quarter_end": lambda s: s.dt.is_quarter_end.astype("float64"),
51
- "is_year_start": lambda s: s.dt.is_year_start.astype("float64"),
52
- "is_year_end": lambda s: s.dt.is_year_end.astype("float64"),
46
+ "is_weekend": lambda s: (s.dt.dayofweek >= 5).astype("float64").where(s.notna()),
47
+ "is_month_start": lambda s: s.dt.is_month_start.astype("float64").where(s.notna()),
48
+ "is_month_end": lambda s: s.dt.is_month_end.astype("float64").where(s.notna()),
49
+ "is_quarter_start": lambda s: s.dt.is_quarter_start.astype("float64").where(s.notna()),
50
+ "is_quarter_end": lambda s: s.dt.is_quarter_end.astype("float64").where(s.notna()),
51
+ "is_year_start": lambda s: s.dt.is_year_start.astype("float64").where(s.notna()),
52
+ "is_year_end": lambda s: s.dt.is_year_end.astype("float64").where(s.notna()),
53
53
  "month_sin": lambda s: np.sin(2 * np.pi * s.dt.month / 12.0),
54
54
  "month_cos": lambda s: np.cos(2 * np.pi * s.dt.month / 12.0),
55
55
  "dayofweek_sin": lambda s: np.sin(2 * np.pi * s.dt.dayofweek / 7.0),
@@ -437,6 +437,12 @@ class OrdinalEncoder(_CategoricalBase):
437
437
  subset of codes, and for genuinely ordered columns. It imposes a false ordering on
438
438
  a nominal column fed to a linear or distance-based model, which is why the planner
439
439
  only selects it for ``model_family="tree"`` or ``SemanticType.ORDINAL``.
440
+
441
+ The ``dtype`` parameter (default ``"int32"``) is applied to encoded columns. When
442
+ missing values are present and ``dtype`` cannot represent NA (such as standard numpy
443
+ integer types), the column safely falls back to ``"float64"`` so downstream imputers
444
+ can process it. Nullable integer dtypes (e.g. ``"Int32"``) are preserved with missing
445
+ values.
440
446
  """
441
447
 
442
448
  stage = Stage.ENCODE
@@ -497,7 +503,13 @@ class OrdinalEncoder(_CategoricalBase):
497
503
  # NaN in the input stays NaN so a later imputer can see it; only
498
504
  # genuinely unseen categories get the sentinel code.
499
505
  codes = codes.mask(unknown, self.unknown_value)
500
- replacements[column] = codes.astype("float64")
506
+ if codes.isna().any():
507
+ try:
508
+ replacements[column] = codes.astype(self.dtype)
509
+ except (ValueError, TypeError):
510
+ replacements[column] = codes.astype("float64")
511
+ else:
512
+ replacements[column] = codes.astype(self.dtype)
501
513
 
502
514
  if unknown_counts:
503
515
  context.journal.warn(
@@ -14,7 +14,7 @@ column that is mostly missing is reported rather than quietly invented.
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
- from typing import Any, Dict, List, Optional, Sequence
17
+ from typing import Any, Dict, List, Optional, Sequence, Set, Tuple, Type
18
18
 
19
19
  import numpy as np
20
20
  import pandas as pd
@@ -31,6 +31,45 @@ __all__ = ["MissingValueHandler", "MissingIndicator"]
31
31
  _LEARNED = frozenset({"mean", "median", "mode"})
32
32
  #: Strategies applied row-wise at transform time with no learned state.
33
33
  _ROWWISE = frozenset({"ffill", "bfill"})
34
+ #: Multivariate imputers (scikit-learn, optional dependency).
35
+ _SKLEARN_IMPUTE = frozenset({"knn", "iterative"})
36
+
37
+ _ADVANCED_INSTALL_MSG = (
38
+ "Install the optional dependency with: pip install 'edaprep[advanced]' "
39
+ "(requires scikit-learn>=1.1)."
40
+ )
41
+
42
+
43
+ def _valid_imputation_strategies() -> List[str]:
44
+ return sorted(
45
+ _LEARNED
46
+ | _ROWWISE
47
+ | _SKLEARN_IMPUTE
48
+ | {"constant", "missing_category", "none", "drop_rows"}
49
+ )
50
+
51
+
52
+ def _is_numeric_imputation_column(series: pd.Series) -> bool:
53
+ return pd.api.types.is_numeric_dtype(series.dtype) and not pd.api.types.is_bool_dtype(
54
+ series.dtype
55
+ )
56
+
57
+
58
+ def _load_sklearn_imputers() -> Tuple[Type[Any], Type[Any]]:
59
+ try:
60
+ from sklearn.impute import KNNImputer
61
+ except ImportError as exc: # pragma: no cover - exercised via mock in tests
62
+ raise ConfigurationError(
63
+ f"strategy='knn' or 'iterative' requires scikit-learn. {_ADVANCED_INSTALL_MSG}"
64
+ ) from exc
65
+ try:
66
+ from sklearn.experimental import enable_iterative_imputer # noqa: F401
67
+ from sklearn.impute import IterativeImputer
68
+ except ImportError as exc: # pragma: no cover
69
+ raise ConfigurationError(
70
+ f"strategy='iterative' requires scikit-learn. {_ADVANCED_INSTALL_MSG}"
71
+ ) from exc
72
+ return KNNImputer, IterativeImputer
34
73
 
35
74
 
36
75
  class MissingValueHandler(Transformer, ColumnTransformerMixin):
@@ -43,7 +82,8 @@ class MissingValueHandler(Transformer, ColumnTransformerMixin):
43
82
  (robust to the skew real tabular data is full of), mode for categorical
44
83
  and binary, and an explicit ``"missing"`` category for high-cardinality
45
84
  categoricals where the mode is not representative. Any other value pins
46
- every column to that strategy.
85
+ every column to that strategy. ``"knn"`` and ``"iterative"`` use
86
+ scikit-learn multivariate imputers (``edaprep[advanced]``).
47
87
  fill_value :
48
88
  Used by ``strategy="constant"``.
49
89
  per_column :
@@ -117,6 +157,118 @@ class MissingValueHandler(Transformer, ColumnTransformerMixin):
117
157
  return "mode"
118
158
  return "missing_category"
119
159
 
160
+ def _eligible_numeric_columns(self, X: pd.DataFrame, context: FitContext) -> List[str]:
161
+ """All numeric columns in ``X`` usable as multivariate predictors (ordered)."""
162
+ target = context.target
163
+ eligible: List[str] = []
164
+ for column in map(str, X.columns):
165
+ if column == target:
166
+ continue
167
+ if not _is_numeric_imputation_column(X[column]):
168
+ continue
169
+ eligible.append(column)
170
+ return eligible
171
+
172
+ def _require_numeric_for_sklearn(self, column: str, series: pd.Series) -> None:
173
+ if _is_numeric_imputation_column(series):
174
+ return
175
+ raise ConfigurationError(
176
+ f"strategy='knn' and 'iterative' are only valid for numeric columns; "
177
+ f"column {column!r} has dtype {series.dtype}. Use 'mode', "
178
+ f"'missing_category', or 'median' as appropriate."
179
+ )
180
+
181
+ def _block_matrix(self, X: pd.DataFrame, block: Sequence[str]) -> np.ndarray:
182
+ """Build a float matrix aligned with ``block`` for sklearn imputers."""
183
+ n_rows = len(X)
184
+ matrix = np.empty((n_rows, len(block)), dtype=np.float64)
185
+ for j, column in enumerate(block):
186
+ values = pd.to_numeric(X[column], errors="coerce").to_numpy(dtype=np.float64)
187
+ matrix[:, j] = values
188
+ return matrix
189
+
190
+ def _fit_sklearn_imputers(self, X: pd.DataFrame, context: FitContext) -> None:
191
+ needs_knn = any(s == "knn" for s in self.strategies_.values())
192
+ needs_iterative = any(s == "iterative" for s in self.strategies_.values())
193
+ self.knn_impute_columns_: List[str] = [
194
+ c for c in self.columns_ if self.strategies_.get(c) == "knn"
195
+ ]
196
+ self.iterative_impute_columns_: List[str] = [
197
+ c for c in self.columns_ if self.strategies_.get(c) == "iterative"
198
+ ]
199
+
200
+ if not needs_knn and not needs_iterative:
201
+ self.imputer_block_columns_: List[str] = []
202
+ self.imputer_block_all_missing_: Set[str] = set()
203
+ self.knn_imputer_ = None
204
+ self.iterative_imputer_ = None
205
+ return
206
+
207
+ KNNImputer, IterativeImputer = _load_sklearn_imputers()
208
+ eligible = self._eligible_numeric_columns(X, context)
209
+ self.imputer_block_all_missing_ = {
210
+ column for column in eligible if len(X) and int(X[column].isna().sum()) == len(X)
211
+ }
212
+ block = [column for column in eligible if column not in self.imputer_block_all_missing_]
213
+ self.imputer_block_columns_ = block
214
+
215
+ if not block:
216
+ self.knn_imputer_ = None
217
+ self.iterative_imputer_ = None
218
+ return
219
+
220
+ fit_matrix = self._block_matrix(X, block)
221
+
222
+ if needs_knn:
223
+ self.knn_imputer_ = KNNImputer()
224
+ self.knn_imputer_.fit(fit_matrix)
225
+ else:
226
+ self.knn_imputer_ = None
227
+
228
+ if needs_iterative:
229
+ self.iterative_imputer_ = IterativeImputer(
230
+ random_state=context.config.random_state,
231
+ sample_posterior=False,
232
+ )
233
+ self.iterative_imputer_.fit(fit_matrix)
234
+ else:
235
+ self.iterative_imputer_ = None
236
+
237
+ def _apply_sklearn_imputations(
238
+ self,
239
+ X: pd.DataFrame,
240
+ replacements: Dict[str, pd.Series],
241
+ filled_counts: Dict[str, int],
242
+ ) -> None:
243
+ block = self.imputer_block_columns_
244
+ if not block:
245
+ return
246
+
247
+ transform_matrix = self._block_matrix(X, block)
248
+ block_index = {name: idx for idx, name in enumerate(block)}
249
+
250
+ for imputer, output_columns in (
251
+ (self.knn_imputer_, self.knn_impute_columns_),
252
+ (self.iterative_imputer_, self.iterative_impute_columns_),
253
+ ):
254
+ if imputer is None:
255
+ continue
256
+ imputed = imputer.transform(transform_matrix)
257
+ for column in output_columns:
258
+ if column not in X.columns or column not in block_index:
259
+ continue
260
+ series = X[column]
261
+ mask = series.isna()
262
+ n_missing = int(mask.sum())
263
+ if n_missing == 0:
264
+ continue
265
+ col_idx = block_index[column]
266
+ filled = series.copy()
267
+ filled.loc[mask] = imputed[mask.to_numpy(), col_idx]
268
+ replacements[column] = filled
269
+ after = int(filled.isna().sum())
270
+ filled_counts[column] = n_missing - after
271
+
120
272
  def _fit(self, X: pd.DataFrame, y: Optional[pd.Series], context: FitContext) -> None:
121
273
  self.strategies_: Dict[str, str] = {}
122
274
  self.fill_values_: Dict[str, Any] = {}
@@ -137,6 +289,19 @@ class MissingValueHandler(Transformer, ColumnTransformerMixin):
137
289
  if strategy in _ROWWISE:
138
290
  continue
139
291
 
292
+ if strategy in _SKLEARN_IMPUTE:
293
+ self._require_numeric_for_sklearn(column, series)
294
+ if n_missing == len(X):
295
+ context.journal.warn(
296
+ "no_data_to_learn_fill",
297
+ f"Column {column!r} is entirely missing in the training data, "
298
+ f"so multivariate imputation cannot learn a fill for it. "
299
+ f"Missing values in this column will be left as-is.",
300
+ Severity.WARNING,
301
+ (column,),
302
+ )
303
+ continue
304
+
140
305
  if strategy == "constant":
141
306
  if self.fill_value is None:
142
307
  override = context.config.get_column(column)
@@ -170,6 +335,8 @@ class MissingValueHandler(Transformer, ColumnTransformerMixin):
170
335
  {"missing_fraction": round(missing_fraction, 4)},
171
336
  )
172
337
 
338
+ self._fit_sklearn_imputers(X, context)
339
+
173
340
  timer.columns = list(self.columns_)
174
341
  timer.params = {"strategy": self.strategy}
175
342
  timer.effect = {
@@ -214,7 +381,7 @@ class MissingValueHandler(Transformer, ColumnTransformerMixin):
214
381
  raise ConfigurationError.unknown_option(
215
382
  "imputation strategy",
216
383
  strategy,
217
- sorted(_LEARNED | _ROWWISE | {"constant", "missing_category", "none"}),
384
+ _valid_imputation_strategies(),
218
385
  )
219
386
 
220
387
  def _transform(self, X: pd.DataFrame, context: FitContext) -> pd.DataFrame:
@@ -225,6 +392,8 @@ class MissingValueHandler(Transformer, ColumnTransformerMixin):
225
392
  with context.journal.timer(
226
393
  self.stage, type(self).__name__, "impute", "transform"
227
394
  ) as timer:
395
+ self._apply_sklearn_imputations(X, replacements, filled_counts)
396
+
228
397
  for column in self.columns_:
229
398
  if column not in X.columns:
230
399
  continue
@@ -239,6 +408,9 @@ class MissingValueHandler(Transformer, ColumnTransformerMixin):
239
408
  if self.add_indicator and n_missing:
240
409
  added[f"{column}__was_missing"] = mask.astype(np.int8)
241
410
 
411
+ if strategy in _SKLEARN_IMPUTE:
412
+ continue
413
+
242
414
  if n_missing == 0:
243
415
  continue
244
416
 
@@ -252,12 +424,13 @@ class MissingValueHandler(Transformer, ColumnTransformerMixin):
252
424
  continue
253
425
  replacements[column] = _fill(series, value)
254
426
 
255
- after = (
256
- int(replacements[column].isna().sum())
257
- if column in replacements
258
- else n_missing
259
- )
260
- filled_counts[column] = n_missing - after
427
+ if column not in filled_counts:
428
+ after = (
429
+ int(replacements[column].isna().sum())
430
+ if column in replacements
431
+ else n_missing
432
+ )
433
+ filled_counts[column] = n_missing - after
261
434
 
262
435
  timer.columns = sorted(filled_counts)
263
436
  timer.effect = {
@@ -106,9 +106,15 @@ class TextColumnHandler(Transformer, ColumnTransformerMixin):
106
106
 
107
107
  added: Dict[str, pd.Series] = {}
108
108
  for column in present:
109
- as_str = X[column].astype(str)
110
- added[f"{column}__length"] = as_str.str.len().astype("float64")
111
- added[f"{column}__n_words"] = as_str.str.split().str.len().astype("float64")
109
+ series = X[column]
110
+ mask = series.isna()
111
+ as_str = series.dropna().astype(str)
112
+ length = as_str.str.len().astype("float64").reindex(series.index)
113
+ n_words = as_str.str.split().str.len().astype("float64").reindex(series.index)
114
+ length[mask] = float("nan")
115
+ n_words[mask] = float("nan")
116
+ added[f"{column}__length"] = length
117
+ added[f"{column}__n_words"] = n_words
112
118
  keep = {str(c): X[c] for c in X.columns if str(c) not in present}
113
119
  return pd.DataFrame({**keep, **added}, index=X.index, copy=False)
114
120
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: edaprep
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Transparent, leakage-safe EDA and ML preprocessing with an explainable planner.
5
5
  Author-email: bijay <bijaybeezoe@gmail.com>
6
6
  License: MIT
@@ -418,3 +418,29 @@ def test_config_repr_is_readable() -> None:
418
418
  text = repr(config)
419
419
  assert "random_state=1" in text
420
420
  assert "column override" in text
421
+
422
+
423
+ def test_categorical_summary_rare_floor_matches_planner() -> None:
424
+ """categorical_summary and the planner must agree on the rare-level floor when
425
+ threshold * n_rows is fractional."""
426
+ from edaprep.eda.categorical import categorical_summary
427
+ from edaprep.planning.planner import Planner
428
+ from edaprep.profiling.profiler import profile
429
+
430
+ # threshold=0.01 (default), n_rows=999 -> 0.01 * 999 = 9.99, floor should be ceil(9.99) = 10
431
+ # 5 levels so planner does not skip due to low cardinality (< 5)
432
+ data = ["cat_a"] * 250 + ["cat_b"] * 250 + ["cat_c"] * 250 + ["cat_d"] * 240 + ["rare"] * 9
433
+ df = pd.DataFrame({"c": data, "y": [0] * 999})
434
+ config = Config(model_family="linear")
435
+ prof = profile(df, target="y", config=config)
436
+
437
+ cat_table = categorical_summary(df, prof, config)
438
+ row = cat_table.set_index("column").loc["c"]
439
+ assert row["n_rare_levels"] == 1
440
+
441
+ plan = Planner(config).plan(prof)
442
+ rare_step = [
443
+ d for d in plan.decisions if d.action == "group_rare_categories" and d.column == "c"
444
+ ]
445
+ assert len(rare_step) == 1
446
+ assert rare_step[0].params["min_count"] == 10
@@ -129,6 +129,44 @@ def test_scaler_uses_train_statistics_only(split_frame) -> None:
129
129
  np.testing.assert_allclose(out["num"].to_numpy(), expected.to_numpy())
130
130
 
131
131
 
132
+ def test_imputer_knn_transform_is_independent_of_batching(split_frame) -> None:
133
+ train, test = split_frame
134
+ train = train.copy()
135
+ test = test.copy()
136
+ train.loc[train.index[:40], "num"] = np.nan
137
+ test.loc[test.index[:15], "num"] = np.nan
138
+
139
+ context = _context(train)
140
+ handler = MissingValueHandler(["num", "skewed"], strategy="knn").fit(
141
+ train, train["y"], context
142
+ )
143
+ whole = handler.transform(test, context)
144
+ row_by_row = pd.concat(
145
+ [handler.transform(test.iloc[[i]], context) for i in range(len(test))],
146
+ axis=0,
147
+ )
148
+ pd.testing.assert_frame_equal(whole, row_by_row)
149
+
150
+
151
+ def test_imputer_iterative_transform_is_independent_of_batching(split_frame) -> None:
152
+ train, test = split_frame
153
+ train = train.copy()
154
+ test = test.copy()
155
+ train.loc[train.index[:40], "num"] = np.nan
156
+ test.loc[test.index[:15], "num"] = np.nan
157
+
158
+ context = _context(train)
159
+ handler = MissingValueHandler(["num", "skewed"], strategy="iterative").fit(
160
+ train, train["y"], context
161
+ )
162
+ whole = handler.transform(test, context)
163
+ row_by_row = pd.concat(
164
+ [handler.transform(test.iloc[[i]], context) for i in range(len(test))],
165
+ axis=0,
166
+ )
167
+ pd.testing.assert_frame_equal(whole, row_by_row)
168
+
169
+
132
170
  def test_imputer_uses_train_median_only(split_frame) -> None:
133
171
  train, test = split_frame
134
172
  train = train.copy()
@@ -678,6 +678,24 @@ def test_column_with_no_missing_and_no_placeholders_plans_no_imputation() -> Non
678
678
  assert not any(a.startswith("impute_") for a in actions), actions
679
679
 
680
680
 
681
+ def test_impute_rationale_names_outlier_strategy_when_nothing_is_missing() -> None:
682
+ """Regression for #47: imputation planned only because outliers may introduce NaN
683
+ must say so, rather than reporting a bare (and correct, but uninformative) 0%."""
684
+ gen = np.random.default_rng(5)
685
+ frame = pd.DataFrame(
686
+ {"amount": gen.normal(50, 10, size=100), "y": gen.integers(0, 2, size=100)}
687
+ )
688
+ assert frame["amount"].isna().sum() == 0
689
+
690
+ plan = Planner(Config(random_state=0, outlier_strategy="impute")).plan(
691
+ profile(frame, target="y")
692
+ )
693
+ decision = next(
694
+ d for d in plan.decisions if d.column == "amount" and d.action.startswith("impute_")
695
+ )
696
+ assert "outlier" in decision.rationale
697
+
698
+
681
699
  def test_placeholder_strings_get_a_missing_indicator() -> None:
682
700
  """Placeholder strings become NaN at Stage.CAST, so MISSING_FLAG must plan a flag.
683
701
 
@@ -897,3 +915,37 @@ def test_from_dict_round_trip_is_warning_free() -> None:
897
915
  assert restored.random_state == 7
898
916
  assert restored.verbose is True
899
917
  assert restored.column("age").imputation == "median"
918
+
919
+
920
+ def test_from_dict_tolerates_retired_thresholds() -> None:
921
+ """Config.from_dict must drop unrecognised Thresholds settings with a warning."""
922
+ data = Config().to_dict()
923
+ data["thresholds"]["old_removed_field"] = 0.5
924
+
925
+ with pytest.warns(UserWarning, match="old_removed_field"):
926
+ restored = Config.from_dict(data)
927
+
928
+ assert isinstance(restored, Config)
929
+ assert not hasattr(restored.thresholds, "old_removed_field")
930
+
931
+
932
+ def test_from_dict_tolerates_retired_column_settings() -> None:
933
+ """Config.from_dict must drop unrecognised per-column settings with a warning."""
934
+ data = Config().to_dict()
935
+ data["columns"] = {"age": {"imputation": "median", "old_removed_col_field": "x"}}
936
+
937
+ with pytest.warns(UserWarning, match="old_removed_col_field"):
938
+ restored = Config.from_dict(data)
939
+
940
+ assert isinstance(restored, Config)
941
+ col = restored.get_column("age")
942
+ assert col is not None
943
+ assert col.imputation == "median"
944
+ assert not hasattr(col, "old_removed_col_field")
945
+
946
+
947
+ def test_set_columns_still_raises_on_unknown_setting_by_default() -> None:
948
+ """Direct user-facing set_columns still raises on typo."""
949
+ config = Config()
950
+ with pytest.raises(ConfigurationError):
951
+ config.set_columns({"age": {"typo_option": "bad"}})
@@ -362,6 +362,191 @@ def test_missing_indicator_respects_threshold() -> None:
362
362
  assert indicator.columns_ == []
363
363
 
364
364
 
365
+ def _correlated_imputation_frame() -> pd.DataFrame:
366
+ rng = np.random.default_rng(21)
367
+ n = 120
368
+ y = rng.normal(0, 1, n)
369
+ x = 2.0 * y + rng.normal(0, 0.05, n)
370
+ frame = pd.DataFrame({"x": x, "y": y})
371
+ frame.loc[frame.index[::4], "x"] = np.nan
372
+ return frame
373
+
374
+
375
+ def test_knn_imputation_uses_correlated_feature_not_median() -> None:
376
+ frame = _correlated_imputation_frame()
377
+ context = ctx(frame)
378
+ median_out = MissingValueHandler(["x"], strategy="median").fit_transform(
379
+ frame, None, context
380
+ )
381
+ knn_out = MissingValueHandler(["x"], strategy="knn").fit_transform(frame, None, context)
382
+ mask = frame["x"].isna()
383
+ median_fill = float(frame["x"].median())
384
+ imputed = knn_out.loc[mask, "x"].to_numpy()
385
+ expected = (2.0 * frame.loc[mask, "y"]).to_numpy()
386
+ assert np.corrcoef(imputed, expected)[0, 1] > 0.95
387
+ assert not np.allclose(imputed, median_fill, rtol=1e-6, atol=1e-6)
388
+ assert not np.allclose(imputed, median_out.loc[mask, "x"].to_numpy(), rtol=1e-6, atol=1e-6)
389
+
390
+
391
+ def test_iterative_imputation_uses_correlated_feature_not_median() -> None:
392
+ frame = _correlated_imputation_frame()
393
+ context = ctx(frame)
394
+ median_out = MissingValueHandler(["x"], strategy="median").fit_transform(
395
+ frame, None, context
396
+ )
397
+ iterative_out = MissingValueHandler(["x"], strategy="iterative").fit_transform(
398
+ frame, None, context
399
+ )
400
+ mask = frame["x"].isna()
401
+ median_fill = float(frame["x"].median())
402
+ imputed = iterative_out.loc[mask, "x"].to_numpy()
403
+ expected = (2.0 * frame.loc[mask, "y"]).to_numpy()
404
+ assert np.corrcoef(imputed, expected)[0, 1] > 0.95
405
+ assert not np.allclose(imputed, median_fill, rtol=1e-6, atol=1e-6)
406
+ assert not np.allclose(imputed, median_out.loc[mask, "x"].to_numpy(), rtol=1e-6, atol=1e-6)
407
+
408
+
409
+ def test_knn_uses_numeric_predictor_outside_columns_and_leaves_it_unchanged() -> None:
410
+ """Only ``x`` is handled, but ``y`` in ``X`` must still inform KNN imputation."""
411
+ frame = _correlated_imputation_frame()
412
+ context = ctx(frame)
413
+ handler = MissingValueHandler(["x"], strategy="knn").fit(frame, None, context)
414
+ assert "y" not in handler.columns_
415
+ assert handler.imputer_block_columns_ == ["x", "y"]
416
+ out = handler.transform(frame, context)
417
+ mask = frame["x"].isna()
418
+ imputed = out.loc[mask, "x"].to_numpy()
419
+ expected = (2.0 * frame.loc[mask, "y"]).to_numpy()
420
+ assert np.corrcoef(imputed, expected)[0, 1] > 0.95
421
+ assert np.allclose(out["y"].to_numpy(), frame["y"].to_numpy())
422
+ assert list(out.columns) == list(frame.columns)
423
+
424
+
425
+ def test_knn_median_predictor_column_stays_in_imputer_block() -> None:
426
+ """Column y uses median but must still be a KNN predictor for x."""
427
+ frame = _correlated_imputation_frame()
428
+ context = ctx(frame)
429
+ handler = MissingValueHandler(["x", "y"], strategy="median", per_column={"x": "knn"}).fit(
430
+ frame, None, context
431
+ )
432
+ assert handler.imputer_block_columns_ == ["x", "y"]
433
+ assert handler.strategies_["y"] == "median"
434
+ out = handler.transform(frame, context)
435
+ mask = frame["x"].isna()
436
+ imputed = out.loc[mask, "x"].to_numpy()
437
+ expected = (2.0 * frame.loc[mask, "y"]).to_numpy()
438
+ assert np.corrcoef(imputed, expected)[0, 1] > 0.95
439
+ assert np.allclose(out["y"].to_numpy(), frame["y"].to_numpy())
440
+
441
+
442
+ def test_all_missing_predictor_column_is_excluded_and_does_not_distort_knn() -> None:
443
+ frame = _correlated_imputation_frame()
444
+ with_dead = frame.assign(z=np.nan)
445
+ context = ctx(with_dead)
446
+ with_z = MissingValueHandler(["x"], strategy="knn").fit_transform(with_dead, None, context)
447
+ without_z = MissingValueHandler(["x"], strategy="knn").fit_transform(frame, None, context)
448
+ handler = MissingValueHandler(["x"], strategy="knn").fit(with_dead, None, context)
449
+ assert "z" in handler.imputer_block_all_missing_
450
+ assert "z" not in handler.imputer_block_columns_
451
+ mask = frame["x"].isna()
452
+ np.testing.assert_allclose(with_z.loc[mask, "x"], without_z.loc[mask, "x"], rtol=1e-10)
453
+ assert with_z["z"].isna().all()
454
+
455
+
456
+ def test_all_missing_knn_target_column_stays_missing_with_warning() -> None:
457
+ frame = pd.DataFrame({"x": [np.nan] * 12, "y": np.arange(12.0)})
458
+ context = ctx(frame)
459
+ handler = MissingValueHandler(["x"], strategy="knn").fit(frame, None, context)
460
+ assert "x" in handler.imputer_block_all_missing_
461
+ assert "x" not in handler.imputer_block_columns_
462
+ assert any(w.code == "no_data_to_learn_fill" for w in context.journal.warnings)
463
+ out = handler.transform(frame, context)
464
+ assert out["x"].isna().all()
465
+ assert list(out.columns) == list(frame.columns)
466
+ assert np.allclose(out["y"].to_numpy(), frame["y"].to_numpy())
467
+
468
+
469
+ def test_knn_transform_batch_equals_row_by_row() -> None:
470
+ frame = _correlated_imputation_frame()
471
+ context = ctx(frame)
472
+ handler = MissingValueHandler(["x", "y"], strategy="knn").fit(frame, None, context)
473
+ whole = handler.transform(frame, context)
474
+ rows = pd.concat(
475
+ [handler.transform(frame.iloc[[i]], context) for i in range(len(frame))],
476
+ axis=0,
477
+ )
478
+ pd.testing.assert_frame_equal(whole, rows)
479
+
480
+
481
+ def test_iterative_transform_batch_equals_row_by_row() -> None:
482
+ frame = _correlated_imputation_frame()
483
+ context = ctx(frame)
484
+ handler = MissingValueHandler(["x", "y"], strategy="iterative").fit(frame, None, context)
485
+ whole = handler.transform(frame, context)
486
+ rows = pd.concat(
487
+ [handler.transform(frame.iloc[[i]], context) for i in range(len(frame))],
488
+ axis=0,
489
+ )
490
+ pd.testing.assert_frame_equal(whole, rows)
491
+
492
+
493
+ def test_sklearn_imputers_are_not_refit_on_transform() -> None:
494
+ frame = _correlated_imputation_frame()
495
+ context = ctx(frame)
496
+ handler = MissingValueHandler(["x", "y"], strategy="knn").fit(frame, None, context)
497
+ assert handler.knn_imputer_ is not None
498
+
499
+ def _no_knn_fit(*args, **kwargs):
500
+ raise AssertionError("KNNImputer.fit must not run during transform")
501
+
502
+ handler.knn_imputer_.fit = _no_knn_fit # type: ignore[method-assign]
503
+ handler.transform(frame.iloc[:10], context)
504
+
505
+ handler2 = MissingValueHandler(["x", "y"], strategy="iterative").fit(frame, None, context)
506
+ assert handler2.iterative_imputer_ is not None
507
+
508
+ def _no_iter_fit(*args, **kwargs):
509
+ raise AssertionError("IterativeImputer.fit must not run during transform")
510
+
511
+ handler2.iterative_imputer_.fit = _no_iter_fit # type: ignore[method-assign]
512
+ handler2.transform(frame.iloc[:10], context)
513
+
514
+
515
+ def test_knn_imputation_rejects_non_numeric_column() -> None:
516
+ frame = pd.DataFrame({"c": ["a", None, "b"], "x": [1.0, np.nan, 3.0]})
517
+ with pytest.raises(ConfigurationError, match="numeric"):
518
+ MissingValueHandler(["c"], strategy="knn").fit(frame, None, ctx(frame))
519
+
520
+
521
+ def test_iterative_imputation_rejects_non_numeric_column() -> None:
522
+ frame = pd.DataFrame({"c": ["a", None, "b"], "x": [1.0, np.nan, 3.0]})
523
+ with pytest.raises(ConfigurationError, match="numeric"):
524
+ MissingValueHandler(["c"], strategy="iterative").fit(frame, None, ctx(frame))
525
+
526
+
527
+ def test_sklearn_imputation_requires_advanced_extra(monkeypatch) -> None:
528
+ import edaprep.preprocessing.missing as missing_mod
529
+
530
+ def _missing_sklearn():
531
+ raise ConfigurationError(
532
+ "strategy='knn' or 'iterative' requires scikit-learn. "
533
+ "Install the optional dependency with: pip install 'edaprep[advanced]' "
534
+ "(requires scikit-learn>=1.1)."
535
+ )
536
+
537
+ monkeypatch.setattr(missing_mod, "_load_sklearn_imputers", _missing_sklearn)
538
+ frame = pd.DataFrame({"x": [1.0, np.nan, 3.0]})
539
+ with pytest.raises(ConfigurationError, match="edaprep\\[advanced\\]"):
540
+ MissingValueHandler(["x"], strategy="knn").fit(frame, None, ctx(frame))
541
+
542
+
543
+ def test_config_accepts_knn_and_iterative_missing_strategies() -> None:
544
+ Config(missing_strategy="knn")
545
+ Config(missing_strategy="iterative")
546
+ Config().column("age").imputation = "knn"
547
+ Config().column("score").imputation = "iterative"
548
+
549
+
365
550
  # ============================== encoding ==============================================
366
551
 
367
552
 
@@ -455,6 +640,39 @@ def test_ordinal_encoder_marks_unseen_but_keeps_nan_as_nan() -> None:
455
640
  assert pd.isna(out["c"].tolist()[2]) # missing stays missing
456
641
 
457
642
 
643
+ def test_ordinal_encoder_dtype_applied_when_no_missing() -> None:
644
+ frame = pd.DataFrame({"c": ["apple", "banana", "cherry"]})
645
+ context = ctx(frame)
646
+ out_default = OrdinalEncoder(["c"]).fit_transform(frame, None, context)
647
+ assert out_default["c"].dtype == "int32"
648
+ assert out_default["c"].tolist() == [0, 1, 2]
649
+
650
+ out_int16 = OrdinalEncoder(["c"], dtype="int16").fit_transform(frame, None, context)
651
+ assert out_int16["c"].dtype == "int16"
652
+
653
+
654
+ def test_ordinal_encoder_dtype_fallback_on_missing_and_nullable_support() -> None:
655
+ train = pd.DataFrame({"c": ["apple", "banana", "cherry"] * 5})
656
+ context = ctx(train)
657
+ encoder_int32 = OrdinalEncoder(["c"], dtype="int32").fit(train, None, context)
658
+
659
+ # Column with NaN: numpy int32 cannot represent NaN -> safely falls back to float64
660
+ eval_frame = pd.DataFrame({"c": ["apple", "unknown_fruit", None]})
661
+ out_fallback = encoder_int32.transform(eval_frame, context)
662
+ assert out_fallback["c"].dtype == "float64"
663
+ assert out_fallback["c"].iloc[0] == 0.0
664
+ assert out_fallback["c"].iloc[1] == -1.0 # unseen
665
+ assert pd.isna(out_fallback["c"].iloc[2])
666
+
667
+ # Nullable Int32: supports missing values -> retains Int32 dtype
668
+ encoder_nullable = OrdinalEncoder(["c"], dtype="Int32").fit(train, None, context)
669
+ out_nullable = encoder_nullable.transform(eval_frame, context)
670
+ assert str(out_nullable["c"].dtype) == "Int32"
671
+ assert out_nullable["c"].iloc[0] == 0
672
+ assert out_nullable["c"].iloc[1] == -1
673
+ assert pd.isna(out_nullable["c"].iloc[2])
674
+
675
+
458
676
  def test_frequency_encoder_and_unseen_categories() -> None:
459
677
  train = pd.DataFrame({"c": ["a"] * 70 + ["b"] * 30})
460
678
  context = ctx(train)
@@ -611,6 +829,43 @@ def test_datetime_rejects_unknown_features() -> None:
611
829
  DateTimeExpander(["d"], features=["nonsense"]).fit(frame, None, ctx(frame))
612
830
 
613
831
 
832
+ def test_datetime_boolean_features_retain_nan_for_missing_dates() -> None:
833
+ boolean_features = [
834
+ "is_weekend",
835
+ "is_month_start",
836
+ "is_month_end",
837
+ "is_quarter_start",
838
+ "is_quarter_end",
839
+ "is_year_start",
840
+ "is_year_end",
841
+ ]
842
+ frame = pd.DataFrame(
843
+ {"d": pd.to_datetime(["2024-01-01", None, "2024-03-01", "2024-01-06"])}
844
+ )
845
+ context = ctx(frame)
846
+ expander = DateTimeExpander(
847
+ ["d"],
848
+ features=["dayofweek", *boolean_features],
849
+ drop_constant=False,
850
+ ).fit(frame, None, context)
851
+ out = expander.transform(frame, context)
852
+
853
+ # Row 1 is NaT: all boolean features must be NaN, not 0.0 or False
854
+ for feat in boolean_features:
855
+ col = f"d__{feat}"
856
+ assert col in out.columns
857
+ assert np.isnan(out.loc[1, col]), f"{col} should be NaN for NaT row"
858
+
859
+ # Row 0 is 2024-01-01 (Monday, month start, quarter start, year start):
860
+ assert out.loc[0, "d__is_weekend"] == 0.0
861
+ assert out.loc[0, "d__is_month_start"] == 1.0
862
+ assert out.loc[0, "d__is_quarter_start"] == 1.0
863
+ assert out.loc[0, "d__is_year_start"] == 1.0
864
+
865
+ # Row 3 is 2024-01-06 (Saturday):
866
+ assert out.loc[3, "d__is_weekend"] == 1.0
867
+
868
+
614
869
  # ============================== casting ===============================================
615
870
 
616
871
 
@@ -627,6 +882,16 @@ def test_whitespace_is_stripped() -> None:
627
882
  assert out["c"].tolist() == ["USA", "usa", "USA"]
628
883
 
629
884
 
885
+ def test_whitespace_stripped_count_ignores_missing_values() -> None:
886
+ frame = pd.DataFrame({"c": [" A ", "B", None, " C "]})
887
+ context = ctx(frame)
888
+
889
+ DataTypeInference(["c"]).fit_transform(frame, None, context)
890
+
891
+ entry = context.journal.transform_entries()[-1]
892
+ assert entry.effect["per_column"]["c"]["stripped"] == 2
893
+
894
+
630
895
  def test_numeric_strings_are_parsed() -> None:
631
896
  gen = np.random.default_rng(15)
632
897
  frame = pd.DataFrame({"n": [f"{v:.2f}" for v in gen.normal(50, 10, 200)]})
@@ -830,6 +1095,21 @@ def test_text_length_features() -> None:
830
1095
  assert "t__length" in out.columns and "t__n_words" in out.columns
831
1096
 
832
1097
 
1098
+ def test_text_length_features_missing() -> None:
1099
+ frame = pd.DataFrame({"t": ["hello world", "a longer sentence here", None, "short", None]})
1100
+ out = TextColumnHandler(strategy="length_features", columns=["t"]).fit_transform(
1101
+ frame, None, ctx(frame)
1102
+ )
1103
+ assert out["t__length"].iloc[0] == 11.0
1104
+ assert out["t__n_words"].iloc[0] == 2.0
1105
+ assert pd.isna(out["t__length"].iloc[2])
1106
+ assert pd.isna(out["t__n_words"].iloc[2])
1107
+ assert pd.isna(out["t__length"].iloc[4])
1108
+ assert pd.isna(out["t__n_words"].iloc[4])
1109
+ assert out["t__length"].iloc[3] == 5.0
1110
+ assert out["t__n_words"].iloc[3] == 1.0
1111
+
1112
+
833
1113
  # ============================== the contract ===========================================
834
1114
 
835
1115
 
@@ -1 +0,0 @@
1
- __version__ = "0.2.2"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes