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.
- {edaprep-0.2.2/src/edaprep.egg-info → edaprep-0.3.0}/PKG-INFO +1 -1
- edaprep-0.3.0/src/edaprep/_version.py +1 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/config.py +44 -4
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/eda/categorical.py +2 -1
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/planning/rules.py +5 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/preprocessing/casting.py +1 -1
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/preprocessing/datetime_features.py +7 -7
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/preprocessing/encoding.py +13 -1
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/preprocessing/missing.py +182 -9
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/preprocessing/text.py +9 -3
- {edaprep-0.2.2 → edaprep-0.3.0/src/edaprep.egg-info}/PKG-INFO +1 -1
- {edaprep-0.2.2 → edaprep-0.3.0}/tests/test_eda.py +26 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/tests/test_leakage.py +38 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/tests/test_pipeline.py +52 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/tests/test_transformers.py +280 -0
- edaprep-0.2.2/src/edaprep/_version.py +0 -1
- {edaprep-0.2.2 → edaprep-0.3.0}/LICENSE +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/README.md +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/pyproject.toml +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/setup.cfg +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/__init__.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/backends/__init__.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/backends/base.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/backends/pandas_backend.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/core/__init__.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/core/base.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/core/context.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/core/journal.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/core/pipeline.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/eda/__init__.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/eda/analyzer.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/eda/correlation.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/eda/numerical.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/eda/outliers.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/eda/target.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/exceptions.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/planning/__init__.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/planning/decisions.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/planning/planner.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/preprocessing/__init__.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/preprocessing/duplicates.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/preprocessing/outliers.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/preprocessing/scaling.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/preprocessing/selection.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/preprocessing/transformations.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/profiling/__init__.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/profiling/column_types.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/profiling/profiler.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/profiling/quality.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/profiling/statistics.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/py.typed +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/reporting/__init__.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/reporting/html.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/reporting/report.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/types.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/visualization/__init__.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep/visualization/plots.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep.egg-info/SOURCES.txt +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep.egg-info/dependency_links.txt +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep.egg-info/requires.txt +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/src/edaprep.egg-info/top_level.txt +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/tests/test_column_types.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/tests/test_profiler.py +0 -0
- {edaprep-0.2.2 → edaprep-0.3.0}/tests/test_statistics.py +0 -0
|
@@ -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
|
-
{
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|