edaprep 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. edaprep/__init__.py +188 -0
  2. edaprep/_version.py +1 -0
  3. edaprep/backends/__init__.py +11 -0
  4. edaprep/backends/base.py +167 -0
  5. edaprep/backends/pandas_backend.py +128 -0
  6. edaprep/config.py +504 -0
  7. edaprep/core/__init__.py +7 -0
  8. edaprep/core/base.py +314 -0
  9. edaprep/core/context.py +81 -0
  10. edaprep/core/journal.py +244 -0
  11. edaprep/core/pipeline.py +587 -0
  12. edaprep/eda/__init__.py +22 -0
  13. edaprep/eda/analyzer.py +486 -0
  14. edaprep/eda/categorical.py +100 -0
  15. edaprep/eda/correlation.py +183 -0
  16. edaprep/eda/numerical.py +86 -0
  17. edaprep/eda/outliers.py +99 -0
  18. edaprep/eda/target.py +214 -0
  19. edaprep/exceptions.py +161 -0
  20. edaprep/planning/__init__.py +16 -0
  21. edaprep/planning/decisions.py +358 -0
  22. edaprep/planning/planner.py +364 -0
  23. edaprep/planning/rules.py +819 -0
  24. edaprep/preprocessing/__init__.py +67 -0
  25. edaprep/preprocessing/casting.py +260 -0
  26. edaprep/preprocessing/datetime_features.py +208 -0
  27. edaprep/preprocessing/duplicates.py +170 -0
  28. edaprep/preprocessing/encoding.py +804 -0
  29. edaprep/preprocessing/missing.py +363 -0
  30. edaprep/preprocessing/outliers.py +569 -0
  31. edaprep/preprocessing/scaling.py +228 -0
  32. edaprep/preprocessing/selection.py +468 -0
  33. edaprep/preprocessing/text.py +124 -0
  34. edaprep/preprocessing/transformations.py +379 -0
  35. edaprep/profiling/__init__.py +19 -0
  36. edaprep/profiling/column_types.py +498 -0
  37. edaprep/profiling/profiler.py +1095 -0
  38. edaprep/profiling/quality.py +357 -0
  39. edaprep/profiling/statistics.py +516 -0
  40. edaprep/py.typed +0 -0
  41. edaprep/reporting/__init__.py +5 -0
  42. edaprep/reporting/html.py +173 -0
  43. edaprep/reporting/report.py +317 -0
  44. edaprep/types.py +189 -0
  45. edaprep/visualization/__init__.py +33 -0
  46. edaprep/visualization/plots.py +330 -0
  47. edaprep-0.1.0.dist-info/METADATA +306 -0
  48. edaprep-0.1.0.dist-info/RECORD +51 -0
  49. edaprep-0.1.0.dist-info/WHEEL +5 -0
  50. edaprep-0.1.0.dist-info/licenses/LICENSE +21 -0
  51. edaprep-0.1.0.dist-info/top_level.txt +1 -0
edaprep/__init__.py ADDED
@@ -0,0 +1,188 @@
1
+ """edaprep: transparent, leakage-safe EDA and ML preprocessing.
2
+
3
+ Quick start
4
+ -----------
5
+
6
+ Understand a dataset::
7
+
8
+ import edaprep
9
+
10
+ profile = edaprep.profile(df, target="churn")
11
+ print(profile.summary())
12
+
13
+ report = edaprep.EDA(df, target="churn").analyze()
14
+ print(report.summary())
15
+
16
+ Prepare it, automatically but not opaquely::
17
+
18
+ pipe = edaprep.AutoPipeline(target="churn", model_family="tree", random_state=42)
19
+ pipe.fit(train_df)
20
+ pipe.explain() # why every column was treated as it was
21
+
22
+ X_train = pipe.transform(train_df)
23
+ X_test = pipe.transform(test_df) # same fitted statistics, no leakage
24
+
25
+ print(pipe.report_.summary())
26
+
27
+ Or say exactly what should happen::
28
+
29
+ pipe = (
30
+ edaprep.Pipeline(target="churn")
31
+ .handle_missing()
32
+ .handle_outliers(strategy="clip")
33
+ .encode_categorical()
34
+ .scale_numeric()
35
+ )
36
+ X = pipe.fit_transform(train_df)
37
+
38
+ Design guarantees
39
+ -----------------
40
+ * Every learned statistic is fitted on the training frame only; ``transform`` is a
41
+ pure function of that fitted state.
42
+ * Every automatic decision is inspectable (``pipe.plan_``), explainable
43
+ (``pipe.explain()``), overridable (``config.column("age").imputation = "mean"``)
44
+ and reproducible (``random_state``, serialisable plan and report).
45
+ * Nothing is silently discarded. Dropped columns, imputed values, grouped
46
+ categories and clipped rows are all counted and reported.
47
+ """
48
+
49
+ from ._version import __version__
50
+ from .config import AUTO, ColumnConfig, Config, Thresholds
51
+ from .core.base import Transformer
52
+ from .core.context import FitContext
53
+ from .core.pipeline import AutoPipeline, Pipeline
54
+ from .eda.analyzer import EDA, EDAReport
55
+ from .exceptions import (
56
+ ConfigurationError,
57
+ DataError,
58
+ EdaPrepError,
59
+ EmptyDataError,
60
+ LeakageError,
61
+ NotFittedError,
62
+ SchemaError,
63
+ TransformationError,
64
+ )
65
+ from .planning.decisions import Decision, Plan, PlannedStep
66
+ from .planning.planner import Planner
67
+ from .planning.rules import Rule, RuleSet, default_rules
68
+ from .preprocessing import (
69
+ CategoricalEncoder,
70
+ ColumnDropper,
71
+ ConstantFilter,
72
+ CorrelationFilter,
73
+ DataTypeInference,
74
+ DateTimeExpander,
75
+ DistributionTransformer,
76
+ DuplicateColumnFilter,
77
+ DuplicateRowHandler,
78
+ FrequencyEncoder,
79
+ MissingIndicator,
80
+ MissingnessFilter,
81
+ MissingValueHandler,
82
+ OneHotEncoder,
83
+ OrdinalEncoder,
84
+ OutlierHandler,
85
+ RareCategoryGrouper,
86
+ Scaler,
87
+ TargetEncoder,
88
+ TextColumnHandler,
89
+ VarianceFilter,
90
+ detect_outliers,
91
+ )
92
+ from .profiling.profiler import ColumnProfile, DatasetProfile, profile
93
+ from .reporting.report import Report
94
+ from .types import AnalysisLevel, ModelFamily, SemanticType, Severity, Stage
95
+
96
+ __all__ = [
97
+ "__version__",
98
+ # entry points
99
+ "profile",
100
+ "EDA",
101
+ "AutoPipeline",
102
+ "Pipeline",
103
+ # configuration
104
+ "Config",
105
+ "ColumnConfig",
106
+ "Thresholds",
107
+ "AUTO",
108
+ # planning
109
+ "Planner",
110
+ "Plan",
111
+ "PlannedStep",
112
+ "Decision",
113
+ "Rule",
114
+ "RuleSet",
115
+ "default_rules",
116
+ # results
117
+ "DatasetProfile",
118
+ "ColumnProfile",
119
+ "Report",
120
+ "EDAReport",
121
+ # types
122
+ "SemanticType",
123
+ "ModelFamily",
124
+ "Stage",
125
+ "Severity",
126
+ "AnalysisLevel",
127
+ # extension
128
+ "Transformer",
129
+ "FitContext",
130
+ # transformers
131
+ "DataTypeInference",
132
+ "DateTimeExpander",
133
+ "DuplicateRowHandler",
134
+ "MissingValueHandler",
135
+ "MissingIndicator",
136
+ "OutlierHandler",
137
+ "detect_outliers",
138
+ "CategoricalEncoder",
139
+ "OneHotEncoder",
140
+ "OrdinalEncoder",
141
+ "FrequencyEncoder",
142
+ "TargetEncoder",
143
+ "RareCategoryGrouper",
144
+ "Scaler",
145
+ "DistributionTransformer",
146
+ "TextColumnHandler",
147
+ "ColumnDropper",
148
+ "ConstantFilter",
149
+ "MissingnessFilter",
150
+ "DuplicateColumnFilter",
151
+ "CorrelationFilter",
152
+ "VarianceFilter",
153
+ # exceptions
154
+ "EdaPrepError",
155
+ "ConfigurationError",
156
+ "NotFittedError",
157
+ "SchemaError",
158
+ "DataError",
159
+ "EmptyDataError",
160
+ "TransformationError",
161
+ "LeakageError",
162
+ ]
163
+
164
+
165
+ def __getattr__(name: str):
166
+ """Lazily expose optional subpackages.
167
+
168
+ ``edaprep.visualization`` needs matplotlib, which is an optional dependency.
169
+ Importing it eagerly would make ``import edaprep`` fail for users who installed
170
+ only the core, so it is resolved on first access with a message that says what to
171
+ install.
172
+ """
173
+ if name == "visualization":
174
+ try:
175
+ # importlib rather than `from . import visualization`: the latter goes
176
+ # through _handle_fromlist, which calls getattr on this module again and
177
+ # re-enters __getattr__, recursing until the stack blows instead of
178
+ # surfacing the ImportError.
179
+ import importlib
180
+
181
+ module = importlib.import_module("edaprep.visualization")
182
+ except ImportError as exc:
183
+ raise ImportError(
184
+ "edaprep.visualization requires matplotlib, which is an optional "
185
+ "dependency. Install it with: pip install 'edaprep[visualization]'"
186
+ ) from exc
187
+ return module
188
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
edaprep/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,11 @@
1
+ """Execution backends.
2
+
3
+ A narrow protocol between the library and a dataframe implementation. See
4
+ :mod:`edaprep.backends.base` for what it covers and, more importantly, what it
5
+ deliberately does not.
6
+ """
7
+
8
+ from .base import Backend, get_backend, register_backend
9
+ from .pandas_backend import PandasBackend
10
+
11
+ __all__ = ["Backend", "PandasBackend", "get_backend", "register_backend"]
@@ -0,0 +1,167 @@
1
+ """The backend protocol.
2
+
3
+ A narrow seam between "what the library wants done to a frame" and "how a particular
4
+ dataframe implementation does it". ``pandas_backend.PandasBackend`` is the only
5
+ implementation today.
6
+
7
+ Why it is this small
8
+ --------------------
9
+ This is not a general dataframe abstraction, and it is deliberately not trying to be.
10
+ Wrapping every pandas call would be abstraction for its own sake: it would slow the
11
+ common path, obscure the code, and buy nothing until a second backend exists.
12
+
13
+ What it does cover is the handful of operations that are (a) hot enough to be worth
14
+ routing through a seam and (b) the ones an Arrow or Polars implementation would
15
+ genuinely do differently. Everything else in the library calls pandas directly.
16
+
17
+ The motivation is concrete rather than speculative: tabular ML frames routinely reach
18
+ 500,000 x 400, and an Arrow-backed implementation is a foreseeable need for frames of
19
+ that shape and larger. Nothing here is used to support a second backend yet.
20
+
21
+ Before writing one
22
+ ------------------
23
+ Read ``docs/performance.md`` first. The one place in this library where a hand-written
24
+ replacement for pandas looked obviously worthwhile turned out to be 2.1x *slower* than
25
+ the pandas code it replaced, and was deleted. Measure before committing to an
26
+ implementation.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from abc import ABC, abstractmethod
32
+ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
33
+
34
+ import numpy as np
35
+
36
+ __all__ = ["Backend", "get_backend", "register_backend"]
37
+
38
+
39
+ class Backend(ABC):
40
+ """Operations the library routes through a backend.
41
+
42
+ Implementations receive and return their own native frame and column types; the
43
+ library treats them as opaque except through these methods.
44
+ """
45
+
46
+ #: Short name, used by :func:`get_backend`.
47
+ name: str = "base"
48
+
49
+ # -- introspection ---------------------------------------------------------------
50
+
51
+ @abstractmethod
52
+ def is_frame(self, obj: Any) -> bool:
53
+ """True when ``obj`` is a frame this backend understands."""
54
+
55
+ @abstractmethod
56
+ def shape(self, frame: Any) -> Tuple[int, int]:
57
+ """``(n_rows, n_columns)``."""
58
+
59
+ @abstractmethod
60
+ def column_names(self, frame: Any) -> List[str]:
61
+ """Column names, in order."""
62
+
63
+ @abstractmethod
64
+ def dtype_of(self, frame: Any, column: str) -> str:
65
+ """A string naming the column's storage type."""
66
+
67
+ @abstractmethod
68
+ def memory_usage(self, frame: Any, deep: bool = True) -> Dict[str, int]:
69
+ """Per-column bytes, plus a ``"total"`` key."""
70
+
71
+ # -- column access ----------------------------------------------------------------
72
+
73
+ @abstractmethod
74
+ def get_column(self, frame: Any, column: str) -> Any:
75
+ """One column, in the backend's native representation."""
76
+
77
+ @abstractmethod
78
+ def to_float_array(self, frame: Any, column: str) -> np.ndarray:
79
+ """One column as a float64 NumPy array, with missing values as NaN.
80
+
81
+ Every numeric kernel in the library ultimately works on NumPy, so this is the
82
+ single conversion point a backend must provide.
83
+ """
84
+
85
+ @abstractmethod
86
+ def select(self, frame: Any, columns: Sequence[str]) -> Any:
87
+ """A frame restricted to ``columns``, without copying where possible."""
88
+
89
+ @abstractmethod
90
+ def assign(self, frame: Any, columns: Mapping[str, Any]) -> Any:
91
+ """A new frame with ``columns`` replaced or appended.
92
+
93
+ Must not mutate ``frame``. Implementations should pass untouched columns
94
+ through by reference rather than copying the whole frame -- the copy discipline
95
+ described in ``docs/architecture.md`` section 6.
96
+ """
97
+
98
+ # -- aggregation -------------------------------------------------------------------
99
+
100
+ @abstractmethod
101
+ def null_mask(self, frame: Any, column: str) -> np.ndarray:
102
+ """Boolean array: True where the value is missing."""
103
+
104
+ @abstractmethod
105
+ def n_unique(self, frame: Any, column: str, dropna: bool = True) -> int:
106
+ """Distinct value count."""
107
+
108
+ @abstractmethod
109
+ def value_counts(
110
+ self, frame: Any, column: str, dropna: bool = True
111
+ ) -> List[Tuple[Any, int]]:
112
+ """``(value, count)`` pairs, most frequent first."""
113
+
114
+ @abstractmethod
115
+ def quantiles(
116
+ self, frame: Any, columns: Sequence[str], levels: Sequence[float]
117
+ ) -> np.ndarray:
118
+ """Quantile matrix of shape ``(len(levels), len(columns))``."""
119
+
120
+ @abstractmethod
121
+ def group_mean(
122
+ self, frame: Any, value_column: str, group_column: str
123
+ ) -> Dict[Any, float]:
124
+ """Mean of ``value_column`` per level of ``group_column``."""
125
+
126
+ @abstractmethod
127
+ def duplicated_rows(
128
+ self, frame: Any, subset: Optional[Sequence[str]] = None
129
+ ) -> np.ndarray:
130
+ """Boolean array: True where the row repeats an earlier one."""
131
+
132
+ # -- construction --------------------------------------------------------------------
133
+
134
+ @abstractmethod
135
+ def concat_columns(self, frames: Iterable[Any]) -> Any:
136
+ """Join frames side by side. Indexes are assumed aligned."""
137
+
138
+ @abstractmethod
139
+ def take_rows(self, frame: Any, mask: np.ndarray) -> Any:
140
+ """The rows where ``mask`` is True."""
141
+
142
+ def __repr__(self) -> str: # pragma: no cover - trivial
143
+ return f"{type(self).__name__}(name={self.name!r})"
144
+
145
+
146
+ _REGISTRY: Dict[str, Backend] = {}
147
+
148
+
149
+ def register_backend(backend: Backend) -> Backend:
150
+ """Make a backend available to :func:`get_backend`."""
151
+ _REGISTRY[backend.name] = backend
152
+ return backend
153
+
154
+
155
+ def get_backend(name: str = "pandas") -> Backend:
156
+ """Look up a registered backend by name."""
157
+ if name not in _REGISTRY:
158
+ if name == "pandas":
159
+ from .pandas_backend import PandasBackend
160
+
161
+ return register_backend(PandasBackend())
162
+ available = ", ".join(sorted(_REGISTRY)) or "pandas"
163
+ raise ValueError(
164
+ f"No backend named {name!r} is registered. Available: {available}. "
165
+ f"Register one with edaprep.backends.register_backend(MyBackend())."
166
+ )
167
+ return _REGISTRY[name]
@@ -0,0 +1,128 @@
1
+ """The pandas backend: the only implementation, and the reference one.
2
+
3
+ Every method here is a thin adapter over a pandas call. That is the point: the protocol
4
+ exists so a *different* implementation can be written, not to add a layer over the one
5
+ that already works.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+
15
+ from .base import Backend
16
+
17
+ __all__ = ["PandasBackend"]
18
+
19
+
20
+ class PandasBackend(Backend):
21
+ """Backend over ``pandas.DataFrame``."""
22
+
23
+ name = "pandas"
24
+
25
+ # -- introspection ---------------------------------------------------------------
26
+
27
+ def is_frame(self, obj: Any) -> bool:
28
+ return isinstance(obj, pd.DataFrame)
29
+
30
+ def shape(self, frame: pd.DataFrame) -> Tuple[int, int]:
31
+ return frame.shape
32
+
33
+ def column_names(self, frame: pd.DataFrame) -> List[str]:
34
+ return [str(c) for c in frame.columns]
35
+
36
+ def dtype_of(self, frame: pd.DataFrame, column: str) -> str:
37
+ return str(frame[column].dtype)
38
+
39
+ def memory_usage(self, frame: pd.DataFrame, deep: bool = True) -> Dict[str, int]:
40
+ usage = frame.memory_usage(index=True, deep=deep)
41
+ return {"total": int(usage.sum()), **{str(k): int(v) for k, v in usage.items()}}
42
+
43
+ # -- column access ----------------------------------------------------------------
44
+
45
+ def get_column(self, frame: pd.DataFrame, column: str) -> pd.Series:
46
+ return frame[column]
47
+
48
+ def to_float_array(self, frame: pd.DataFrame, column: str) -> np.ndarray:
49
+ series = frame[column]
50
+ if isinstance(series.dtype, pd.CategoricalDtype):
51
+ series = series.astype("float64")
52
+ try:
53
+ return series.to_numpy(dtype=np.float64, na_value=np.nan, copy=False)
54
+ except (TypeError, ValueError):
55
+ return pd.to_numeric(series, errors="coerce").to_numpy(dtype=np.float64)
56
+
57
+ def select(self, frame: pd.DataFrame, columns: Sequence[str]) -> pd.DataFrame:
58
+ return frame[list(columns)]
59
+
60
+ def assign(self, frame: pd.DataFrame, columns: Mapping[str, Any]) -> pd.DataFrame:
61
+ if not columns:
62
+ return frame
63
+ # Rebuild from the existing column blocks with only the named columns replaced,
64
+ # rather than `frame.copy()` then assigning: a step that touches 5 of 400
65
+ # columns then allocates 5 columns, not 400.
66
+ data: Dict[str, Any] = {}
67
+ for name in frame.columns:
68
+ key = str(name)
69
+ data[key] = columns.get(key, frame[name])
70
+ for key, value in columns.items():
71
+ if key not in data:
72
+ data[key] = value
73
+ return pd.DataFrame(data, index=frame.index, copy=False)
74
+
75
+ # -- aggregation -------------------------------------------------------------------
76
+
77
+ def null_mask(self, frame: pd.DataFrame, column: str) -> np.ndarray:
78
+ return frame[column].isna().to_numpy()
79
+
80
+ def n_unique(self, frame: pd.DataFrame, column: str, dropna: bool = True) -> int:
81
+ try:
82
+ return int(frame[column].nunique(dropna=dropna))
83
+ except TypeError:
84
+ # Unhashable cell values (lists, dicts); the string view is slower but is
85
+ # the only thing that can be counted at all.
86
+ return int(frame[column].astype(str).nunique(dropna=dropna))
87
+
88
+ def value_counts(
89
+ self, frame: pd.DataFrame, column: str, dropna: bool = True
90
+ ) -> List[Tuple[Any, int]]:
91
+ counts = frame[column].value_counts(dropna=dropna)
92
+ return [(index, int(value)) for index, value in counts.items()]
93
+
94
+ def quantiles(
95
+ self, frame: pd.DataFrame, columns: Sequence[str], levels: Sequence[float]
96
+ ) -> np.ndarray:
97
+ return frame[list(columns)].quantile(list(levels)).to_numpy()
98
+
99
+ def group_mean(
100
+ self, frame: pd.DataFrame, value_column: str, group_column: str
101
+ ) -> Dict[Any, float]:
102
+ grouped = frame.groupby(group_column, observed=True)[value_column].mean()
103
+ return {index: float(value) for index, value in grouped.items()}
104
+
105
+ def duplicated_rows(
106
+ self, frame: pd.DataFrame, subset: Optional[Sequence[str]] = None
107
+ ) -> np.ndarray:
108
+ try:
109
+ return frame.duplicated(subset=list(subset) if subset else None).to_numpy()
110
+ except TypeError:
111
+ return (
112
+ frame.astype(str)
113
+ .duplicated(subset=list(subset) if subset else None)
114
+ .to_numpy()
115
+ )
116
+
117
+ # -- construction --------------------------------------------------------------------
118
+
119
+ def concat_columns(self, frames: Iterable[pd.DataFrame]) -> pd.DataFrame:
120
+ parts = list(frames)
121
+ if not parts:
122
+ return pd.DataFrame()
123
+ if len(parts) == 1:
124
+ return parts[0]
125
+ return pd.concat(parts, axis=1, copy=False)
126
+
127
+ def take_rows(self, frame: pd.DataFrame, mask: np.ndarray) -> pd.DataFrame:
128
+ return frame.loc[np.asarray(mask, dtype=bool)]