polars-learn 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.
- polars_learn/__init__.py +23 -0
- polars_learn/_utils.py +143 -0
- polars_learn/exceptions.py +17 -0
- polars_learn/feature_engineering.py +223 -0
- polars_learn/feature_spec.py +122 -0
- polars_learn/py.typed +0 -0
- polars_learn/schema_validation.py +280 -0
- polars_learn/types.py +15 -0
- polars_learn-0.1.0.dist-info/METADATA +183 -0
- polars_learn-0.1.0.dist-info/RECORD +12 -0
- polars_learn-0.1.0.dist-info/WHEEL +4 -0
- polars_learn-0.1.0.dist-info/licenses/LICENSE +21 -0
polars_learn/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Native Polars feature engineering and schema validation for scikit-learn."""
|
|
2
|
+
|
|
3
|
+
from .exceptions import (
|
|
4
|
+
FeatureEngineeringError,
|
|
5
|
+
PandasConversionWarning,
|
|
6
|
+
PolarsLearnError,
|
|
7
|
+
SchemaValidationError,
|
|
8
|
+
)
|
|
9
|
+
from .feature_engineering import FeatureEngineer
|
|
10
|
+
from .feature_spec import FeatureSpec
|
|
11
|
+
from .schema_validation import SchemaValidator
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.0"
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"FeatureEngineer",
|
|
17
|
+
"FeatureSpec",
|
|
18
|
+
"SchemaValidator",
|
|
19
|
+
"PolarsLearnError",
|
|
20
|
+
"SchemaValidationError",
|
|
21
|
+
"FeatureEngineeringError",
|
|
22
|
+
"PandasConversionWarning",
|
|
23
|
+
]
|
polars_learn/_utils.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Shared input conversion and estimator conventions."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import warnings
|
|
5
|
+
from typing import Any, Self, cast
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import polars as pl
|
|
9
|
+
from numpy.typing import NDArray
|
|
10
|
+
from sklearn.base import BaseEstimator, TransformerMixin
|
|
11
|
+
from sklearn.utils.validation import check_is_fitted # pyright: ignore[reportUnknownVariableType]
|
|
12
|
+
|
|
13
|
+
from .exceptions import PandasConversionWarning
|
|
14
|
+
from .types import FeatureNames, OutputMode
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def to_polars(
|
|
18
|
+
X: object, *, convert_pandas: bool, warn_on_conversion: bool
|
|
19
|
+
) -> pl.DataFrame:
|
|
20
|
+
"""Validate the container and optionally convert pandas input to Polars.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
X : object
|
|
25
|
+
Candidate eager DataFrame. NumPy arrays and LazyFrames are unsupported.
|
|
26
|
+
convert_pandas : bool
|
|
27
|
+
Whether pandas DataFrames may be converted with ``pl.from_pandas``.
|
|
28
|
+
warn_on_conversion : bool
|
|
29
|
+
Whether conversion emits ``PandasConversionWarning``.
|
|
30
|
+
|
|
31
|
+
Returns
|
|
32
|
+
-------
|
|
33
|
+
frame : polars.DataFrame
|
|
34
|
+
Original Polars input or converted pandas DataFrame.
|
|
35
|
+
|
|
36
|
+
Raises
|
|
37
|
+
------
|
|
38
|
+
TypeError
|
|
39
|
+
If input is unsupported, conversion is disabled, or a conversion
|
|
40
|
+
dependency is unavailable.
|
|
41
|
+
|
|
42
|
+
Warns
|
|
43
|
+
-----
|
|
44
|
+
PandasConversionWarning
|
|
45
|
+
If pandas input is converted and warnings are enabled.
|
|
46
|
+
"""
|
|
47
|
+
if isinstance(X, pl.DataFrame):
|
|
48
|
+
return X
|
|
49
|
+
# Inspect the MRO so subclasses work without importing pandas for native inputs.
|
|
50
|
+
if any(cls.__module__.startswith("pandas.") for cls in type(X).__mro__):
|
|
51
|
+
if not convert_pandas:
|
|
52
|
+
raise TypeError(
|
|
53
|
+
"Received pandas input; set convert_pandas=True to enable conversion."
|
|
54
|
+
)
|
|
55
|
+
try:
|
|
56
|
+
pd = importlib.import_module("pandas")
|
|
57
|
+
except ImportError as exc:
|
|
58
|
+
raise TypeError("Pandas conversion requires the 'pandas' extra.") from exc
|
|
59
|
+
if isinstance(X, pd.DataFrame):
|
|
60
|
+
try:
|
|
61
|
+
# Runtime isinstance above narrows an optional, lazily imported type.
|
|
62
|
+
result = cast(pl.DataFrame, pl.from_pandas(cast(Any, X))) # pyright: ignore[reportUnknownMemberType, reportUnnecessaryCast]
|
|
63
|
+
except ImportError as exc:
|
|
64
|
+
raise TypeError(
|
|
65
|
+
"This pandas dtype requires PyArrow; install polars-learn[pandas]."
|
|
66
|
+
) from exc
|
|
67
|
+
if warn_on_conversion:
|
|
68
|
+
warnings.warn(
|
|
69
|
+
"Received a pandas DataFrame and converted it to Polars. "
|
|
70
|
+
"For optimal performance, provide a Polars DataFrame directly; "
|
|
71
|
+
"conversion may add memory and performance overhead.",
|
|
72
|
+
PandasConversionWarning,
|
|
73
|
+
stacklevel=3,
|
|
74
|
+
)
|
|
75
|
+
return result
|
|
76
|
+
raise TypeError("Expected a Polars DataFrame (or explicitly enabled pandas input).")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class NativeTransformer(TransformerMixin, BaseEstimator, auto_wrap_output_keys=None):
|
|
80
|
+
"""Keep native output and extra columns free of wrapper renames."""
|
|
81
|
+
|
|
82
|
+
feature_names_in_: NDArray[np.object_]
|
|
83
|
+
n_features_in_: int
|
|
84
|
+
output_features_: NDArray[np.object_]
|
|
85
|
+
|
|
86
|
+
def get_feature_names_out(
|
|
87
|
+
self, input_features: FeatureNames | None = None
|
|
88
|
+
) -> NDArray[np.object_]:
|
|
89
|
+
"""Return the fitted output column names.
|
|
90
|
+
|
|
91
|
+
Parameters
|
|
92
|
+
----------
|
|
93
|
+
input_features : array-like of str or None, default=None
|
|
94
|
+
If supplied, names must exactly match ``feature_names_in_``.
|
|
95
|
+
|
|
96
|
+
Returns
|
|
97
|
+
-------
|
|
98
|
+
feature_names_out : ndarray of shape (n_features_out_,), dtype=object
|
|
99
|
+
Copy of the fitted output names. For schema validators this
|
|
100
|
+
describes required columns, excluding inference-only extras.
|
|
101
|
+
|
|
102
|
+
Raises
|
|
103
|
+
------
|
|
104
|
+
sklearn.exceptions.NotFittedError
|
|
105
|
+
If the transformer has not been fitted.
|
|
106
|
+
ValueError
|
|
107
|
+
If supplied input names differ from fitted names or order.
|
|
108
|
+
"""
|
|
109
|
+
check_is_fitted(self, "output_features_")
|
|
110
|
+
if input_features is not None and not np.array_equal(
|
|
111
|
+
np.asarray(input_features, dtype=object), self.feature_names_in_
|
|
112
|
+
):
|
|
113
|
+
raise ValueError("input_features must match feature_names_in_ exactly.")
|
|
114
|
+
return self.output_features_.copy()
|
|
115
|
+
|
|
116
|
+
# sklearn exposes set_output as an available_if descriptor.
|
|
117
|
+
def set_output(self, *, transform: OutputMode | None = None) -> Self: # pyright: ignore[reportIncompatibleVariableOverride]
|
|
118
|
+
"""Configure native output without automatic container conversion.
|
|
119
|
+
|
|
120
|
+
Parameters
|
|
121
|
+
----------
|
|
122
|
+
transform : {"default", "polars"} or None, default=None
|
|
123
|
+
Both supported values return Polars DataFrames. ``None`` leaves
|
|
124
|
+
the configuration unchanged. Pandas output is unsupported.
|
|
125
|
+
|
|
126
|
+
Returns
|
|
127
|
+
-------
|
|
128
|
+
self : estimator instance
|
|
129
|
+
This transformer.
|
|
130
|
+
|
|
131
|
+
Raises
|
|
132
|
+
------
|
|
133
|
+
ValueError
|
|
134
|
+
If an unsupported output container is requested.
|
|
135
|
+
|
|
136
|
+
Notes
|
|
137
|
+
-----
|
|
138
|
+
Automatic scikit-learn output wrapping and global output settings are
|
|
139
|
+
bypassed to preserve native names and permitted extra columns.
|
|
140
|
+
"""
|
|
141
|
+
if transform not in (None, "default", "polars"):
|
|
142
|
+
raise ValueError("Only 'default' and 'polars' output are supported.")
|
|
143
|
+
return self
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Public errors and warnings."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PolarsLearnError(Exception):
|
|
5
|
+
"""Base package exception."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SchemaValidationError(PolarsLearnError):
|
|
9
|
+
"""Raised when input schema validation fails."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FeatureEngineeringError(PolarsLearnError):
|
|
13
|
+
"""Raised when a feature transformation fails."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PandasConversionWarning(UserWarning):
|
|
17
|
+
"""Warning emitted when pandas input is converted."""
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Sequential feature engineering for scikit-learn pipelines."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from typing import Self
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import polars as pl
|
|
8
|
+
from sklearn.utils.validation import check_is_fitted # pyright: ignore[reportUnknownVariableType]
|
|
9
|
+
|
|
10
|
+
from ._utils import NativeTransformer, to_polars
|
|
11
|
+
from .exceptions import FeatureEngineeringError
|
|
12
|
+
from .feature_spec import FeatureSpec
|
|
13
|
+
from .types import Schema
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FeatureEngineer(NativeTransformer, auto_wrap_output_keys=None):
|
|
17
|
+
"""Apply ordered, stateless feature specifications to Polars DataFrames.
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
----------
|
|
21
|
+
features : sequence of FeatureSpec
|
|
22
|
+
Specifications evaluated in order. Later specifications may reference
|
|
23
|
+
earlier outputs. An empty sequence produces an identity transformation.
|
|
24
|
+
The constructor retains this parameter unchanged for estimator cloning.
|
|
25
|
+
convert_pandas : bool, default=False
|
|
26
|
+
Allow pandas DataFrames and convert them to Polars. Requires pandas;
|
|
27
|
+
some dtypes also require PyArrow. No conversion occurs for Polars input.
|
|
28
|
+
warn_on_conversion : bool, default=True
|
|
29
|
+
Emit ``PandasConversionWarning`` when converting pandas input.
|
|
30
|
+
|
|
31
|
+
Attributes
|
|
32
|
+
----------
|
|
33
|
+
n_features_in_ : int
|
|
34
|
+
Number of columns observed during fitting.
|
|
35
|
+
feature_names_in_ : ndarray of shape (n_features_in_,), dtype=object
|
|
36
|
+
Input column names in training order.
|
|
37
|
+
input_schema_ : dict of str to polars.DataType
|
|
38
|
+
Input dtypes observed during fitting, independent of other estimators.
|
|
39
|
+
output_schema_ : dict of str to polars.DataType
|
|
40
|
+
Expected transformed names, order, and dtypes.
|
|
41
|
+
output_features_ : ndarray of shape (n_features_out_,), dtype=object
|
|
42
|
+
Output column names, including retained inputs and engineered features.
|
|
43
|
+
|
|
44
|
+
See Also
|
|
45
|
+
--------
|
|
46
|
+
FeatureSpec : Describe one feature operation.
|
|
47
|
+
SchemaValidator : Validate and normalise raw input before engineering.
|
|
48
|
+
|
|
49
|
+
Notes
|
|
50
|
+
-----
|
|
51
|
+
The public API accepts eager DataFrames. Internally, lazy operations retain
|
|
52
|
+
dependency order and collect once per execution. Fitting executes the
|
|
53
|
+
training transformation to validate it but learns no feature statistics.
|
|
54
|
+
Transform requires fitted input names and order and the fitted output schema.
|
|
55
|
+
Both default and explicitly requested Polars output remain native Polars.
|
|
56
|
+
|
|
57
|
+
Examples
|
|
58
|
+
--------
|
|
59
|
+
>>> import polars as pl
|
|
60
|
+
>>> from polars_learn import FeatureEngineer, FeatureSpec
|
|
61
|
+
>>> X = pl.DataFrame({"price": [10.0], "quantity": [2]})
|
|
62
|
+
>>> engineer = FeatureEngineer([
|
|
63
|
+
... FeatureSpec(pl.col("price") * pl.col("quantity"), alias="notional")
|
|
64
|
+
... ])
|
|
65
|
+
>>> engineer.fit_transform(X)["notional"].to_list()
|
|
66
|
+
[20.0]
|
|
67
|
+
>>> engineer.get_feature_names_out().tolist()
|
|
68
|
+
['price', 'quantity', 'notional']
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
features: Sequence[FeatureSpec],
|
|
74
|
+
*,
|
|
75
|
+
convert_pandas: bool = False,
|
|
76
|
+
warn_on_conversion: bool = True,
|
|
77
|
+
) -> None:
|
|
78
|
+
self.features = features
|
|
79
|
+
self.convert_pandas = convert_pandas
|
|
80
|
+
self.warn_on_conversion = warn_on_conversion
|
|
81
|
+
|
|
82
|
+
def _input(self, X: object) -> pl.DataFrame:
|
|
83
|
+
return to_polars(
|
|
84
|
+
X,
|
|
85
|
+
convert_pandas=self.convert_pandas,
|
|
86
|
+
warn_on_conversion=self.warn_on_conversion,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def _execute(self, frame: pl.DataFrame) -> pl.DataFrame:
|
|
90
|
+
if not isinstance(self.features, Sequence) or isinstance(self.features, str):
|
|
91
|
+
raise TypeError(
|
|
92
|
+
"features must be an ordered sequence of FeatureSpec objects."
|
|
93
|
+
)
|
|
94
|
+
plan = frame.lazy()
|
|
95
|
+
labels: list[str] = []
|
|
96
|
+
for index, spec in enumerate(self.features):
|
|
97
|
+
if not isinstance(spec, FeatureSpec):
|
|
98
|
+
raise TypeError(f"features[{index}] must be a FeatureSpec.")
|
|
99
|
+
label = spec.name or f"specification {index}: {spec!r}"
|
|
100
|
+
labels.append(label)
|
|
101
|
+
try:
|
|
102
|
+
plan = plan.with_columns(spec.expressions)
|
|
103
|
+
# Resolve each stage for precise dependency/drop errors, without
|
|
104
|
+
# materialising data until the final collect.
|
|
105
|
+
schema = plan.collect_schema()
|
|
106
|
+
missing = set(spec.drop) - set(schema.names())
|
|
107
|
+
if missing:
|
|
108
|
+
raise ValueError(f"Cannot drop missing columns: {sorted(missing)}")
|
|
109
|
+
if spec.drop:
|
|
110
|
+
plan = plan.drop(spec.drop)
|
|
111
|
+
except Exception as exc:
|
|
112
|
+
raise FeatureEngineeringError(
|
|
113
|
+
f"Failed to generate {label}: {exc}"
|
|
114
|
+
) from exc
|
|
115
|
+
try:
|
|
116
|
+
return plan.collect()
|
|
117
|
+
except Exception as exc:
|
|
118
|
+
raise FeatureEngineeringError(
|
|
119
|
+
f"Failed to execute feature plan ({'; '.join(labels)}): {exc}"
|
|
120
|
+
) from exc
|
|
121
|
+
|
|
122
|
+
def fit(self, X: object, y: object = None) -> Self:
|
|
123
|
+
"""Validate training expressions and learn input and output schemas.
|
|
124
|
+
|
|
125
|
+
Parameters
|
|
126
|
+
----------
|
|
127
|
+
X : polars.DataFrame or pandas.DataFrame of shape (n_samples, n_features)
|
|
128
|
+
Training data. Pandas input requires ``convert_pandas=True``.
|
|
129
|
+
y : object, default=None
|
|
130
|
+
Ignored. Present for scikit-learn estimator compatibility.
|
|
131
|
+
|
|
132
|
+
Returns
|
|
133
|
+
-------
|
|
134
|
+
self : FeatureEngineer
|
|
135
|
+
Fitted transformer.
|
|
136
|
+
|
|
137
|
+
Raises
|
|
138
|
+
------
|
|
139
|
+
TypeError
|
|
140
|
+
If input or feature specifications have unsupported types.
|
|
141
|
+
FeatureEngineeringError
|
|
142
|
+
If expressions or drop operations fail. The underlying exception
|
|
143
|
+
is preserved as the cause.
|
|
144
|
+
"""
|
|
145
|
+
frame = self._input(X)
|
|
146
|
+
result = self._execute(frame)
|
|
147
|
+
self.feature_names_in_ = np.asarray(frame.columns, dtype=object)
|
|
148
|
+
self.n_features_in_ = frame.width
|
|
149
|
+
self.input_schema_: Schema = dict(frame.schema)
|
|
150
|
+
self.output_schema_: Schema = dict(result.schema)
|
|
151
|
+
self.output_features_ = np.asarray(result.columns, dtype=object)
|
|
152
|
+
return self
|
|
153
|
+
|
|
154
|
+
def fit_transform(
|
|
155
|
+
self, X: object, y: object = None, **fit_params: object
|
|
156
|
+
) -> pl.DataFrame:
|
|
157
|
+
"""Fit to training data and return its transformed representation.
|
|
158
|
+
|
|
159
|
+
Parameters
|
|
160
|
+
----------
|
|
161
|
+
X : polars.DataFrame or pandas.DataFrame of shape (n_samples, n_features)
|
|
162
|
+
Training data. Pandas input requires ``convert_pandas=True``.
|
|
163
|
+
y : object, default=None
|
|
164
|
+
Ignored. Present for scikit-learn estimator compatibility.
|
|
165
|
+
**fit_params : dict
|
|
166
|
+
Accepted for the scikit-learn method signature. Additional fit
|
|
167
|
+
parameters are unsupported and raise ``TypeError`` when supplied.
|
|
168
|
+
|
|
169
|
+
Returns
|
|
170
|
+
-------
|
|
171
|
+
X_transformed : polars.DataFrame of shape (n_samples, n_features_out)
|
|
172
|
+
Result of fitting this transformer and transforming the same data.
|
|
173
|
+
|
|
174
|
+
See Also
|
|
175
|
+
--------
|
|
176
|
+
fit : Learn the fitted schema and feature names.
|
|
177
|
+
transform : Apply the fitted transformer and its validation policies.
|
|
178
|
+
"""
|
|
179
|
+
if fit_params:
|
|
180
|
+
raise TypeError(
|
|
181
|
+
f"Additional fit parameters are unsupported: {sorted(fit_params)}."
|
|
182
|
+
)
|
|
183
|
+
return self.fit(X, y).transform(X)
|
|
184
|
+
|
|
185
|
+
def transform(self, X: object) -> pl.DataFrame:
|
|
186
|
+
"""Generate features and verify the fitted output schema.
|
|
187
|
+
|
|
188
|
+
Parameters
|
|
189
|
+
----------
|
|
190
|
+
X : polars.DataFrame or pandas.DataFrame of shape (n_samples, n_features)
|
|
191
|
+
Input with the fitted column names and order. Pandas input requires
|
|
192
|
+
``convert_pandas=True``. Input dtypes may differ only if the final
|
|
193
|
+
output schema still matches the fitted expectations.
|
|
194
|
+
|
|
195
|
+
Returns
|
|
196
|
+
-------
|
|
197
|
+
X_transformed : polars.DataFrame of shape (n_samples, n_features_out)
|
|
198
|
+
Retained and engineered columns in native Polars order.
|
|
199
|
+
|
|
200
|
+
Raises
|
|
201
|
+
------
|
|
202
|
+
sklearn.exceptions.NotFittedError
|
|
203
|
+
If ``fit`` has not completed successfully.
|
|
204
|
+
TypeError
|
|
205
|
+
If the input is unsupported or pandas conversion is disabled.
|
|
206
|
+
FeatureEngineeringError
|
|
207
|
+
If input columns differ, feature execution fails, or the generated
|
|
208
|
+
output schema differs from the fitted schema.
|
|
209
|
+
"""
|
|
210
|
+
check_is_fitted(self, "output_schema_")
|
|
211
|
+
frame = self._input(X)
|
|
212
|
+
if frame.columns != self.feature_names_in_.tolist():
|
|
213
|
+
raise FeatureEngineeringError(
|
|
214
|
+
f"Input columns must match fitted names and order. Expected "
|
|
215
|
+
f"{self.feature_names_in_.tolist()}, received {frame.columns}."
|
|
216
|
+
)
|
|
217
|
+
result = self._execute(frame)
|
|
218
|
+
if list(result.schema.items()) != list(self.output_schema_.items()):
|
|
219
|
+
raise FeatureEngineeringError(
|
|
220
|
+
f"Output schema differs from fit. Expected {self.output_schema_}, "
|
|
221
|
+
f"received {dict(result.schema)}."
|
|
222
|
+
)
|
|
223
|
+
return result
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Composable descriptions of native Polars feature operations."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
|
|
5
|
+
import polars as pl
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FeatureSpec:
|
|
9
|
+
"""Describe a native Polars feature operation.
|
|
10
|
+
|
|
11
|
+
Parameters
|
|
12
|
+
----------
|
|
13
|
+
expression : polars.Expr or None, default=None
|
|
14
|
+
Single expression to evaluate. Specify exactly one of ``expression`` or
|
|
15
|
+
``expressions``. Its native output name is used unless ``alias`` is set.
|
|
16
|
+
expressions : sequence of polars.Expr or None, default=None
|
|
17
|
+
Nonempty sequence of expressions evaluated together. Expressions within
|
|
18
|
+
one specification cannot reference columns created by siblings.
|
|
19
|
+
alias : str or None, default=None
|
|
20
|
+
Override the output name of a single expression. Multiple expressions
|
|
21
|
+
or an expression expanding to multiple outputs cannot use this option.
|
|
22
|
+
drop : sequence of str or None, default=None
|
|
23
|
+
Columns to remove after evaluating the expressions. Missing names fail
|
|
24
|
+
during execution; later specifications cannot use dropped columns.
|
|
25
|
+
name : str or None, default=None
|
|
26
|
+
Descriptive label included in feature execution errors.
|
|
27
|
+
|
|
28
|
+
Attributes
|
|
29
|
+
----------
|
|
30
|
+
expressions : tuple of polars.Expr
|
|
31
|
+
Canonical expression representation, including any explicit alias.
|
|
32
|
+
drop : tuple of str
|
|
33
|
+
Columns to drop after evaluating this specification.
|
|
34
|
+
name : str or None
|
|
35
|
+
Optional debugging label.
|
|
36
|
+
|
|
37
|
+
Raises
|
|
38
|
+
------
|
|
39
|
+
TypeError
|
|
40
|
+
If expressions, drop columns, or their containers have invalid types.
|
|
41
|
+
ValueError
|
|
42
|
+
If expression arguments conflict, expressions are empty, an explicit
|
|
43
|
+
alias is ambiguous, output names are duplicated, or a label is invalid.
|
|
44
|
+
|
|
45
|
+
See Also
|
|
46
|
+
--------
|
|
47
|
+
FeatureEngineer : Execute an ordered sequence of specifications.
|
|
48
|
+
|
|
49
|
+
Notes
|
|
50
|
+
-----
|
|
51
|
+
Existing columns may be overwritten deliberately. Expressions follow native
|
|
52
|
+
Polars naming, so an additional alias is not required when the expression
|
|
53
|
+
already has a determinable output name. Selector names and duplicates that
|
|
54
|
+
depend on the input schema are resolved by the feature engineer.
|
|
55
|
+
|
|
56
|
+
Examples
|
|
57
|
+
--------
|
|
58
|
+
>>> import polars as pl
|
|
59
|
+
>>> from polars_learn import FeatureSpec
|
|
60
|
+
>>> spec = FeatureSpec(
|
|
61
|
+
... pl.col("price") * pl.col("quantity"), alias="notional"
|
|
62
|
+
... )
|
|
63
|
+
>>> spec.expressions[0].meta.output_name()
|
|
64
|
+
'notional'
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
expression: pl.Expr | None = None,
|
|
70
|
+
*,
|
|
71
|
+
expressions: Sequence[pl.Expr] | None = None,
|
|
72
|
+
alias: str | None = None,
|
|
73
|
+
drop: Sequence[str] | None = None,
|
|
74
|
+
name: str | None = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
if (expression is None) == (expressions is None):
|
|
77
|
+
raise ValueError("Specify exactly one of expression or expressions.")
|
|
78
|
+
if expressions is not None and (
|
|
79
|
+
isinstance(expressions, (str, pl.Expr))
|
|
80
|
+
or not isinstance(expressions, Sequence)
|
|
81
|
+
):
|
|
82
|
+
raise TypeError("expressions must be a sequence of Polars expressions.")
|
|
83
|
+
canonical = (
|
|
84
|
+
(expression,) if expression is not None else tuple(expressions or ())
|
|
85
|
+
)
|
|
86
|
+
if not canonical:
|
|
87
|
+
raise ValueError("expressions must not be empty.")
|
|
88
|
+
if any(not isinstance(expr, pl.Expr) for expr in canonical):
|
|
89
|
+
raise TypeError("Every expression must be a Polars Expr.")
|
|
90
|
+
if alias is not None:
|
|
91
|
+
if not isinstance(alias, str) or not alias:
|
|
92
|
+
raise ValueError("alias must be a nonempty string.")
|
|
93
|
+
if len(canonical) != 1 or canonical[0].meta.has_multiple_outputs():
|
|
94
|
+
raise ValueError("An explicit alias requires exactly one output.")
|
|
95
|
+
canonical = (canonical[0].alias(alias),)
|
|
96
|
+
names: list[str] = []
|
|
97
|
+
for expr in canonical:
|
|
98
|
+
if not expr.meta.has_multiple_outputs():
|
|
99
|
+
output_name = expr.meta.output_name(raise_if_undetermined=False)
|
|
100
|
+
if output_name is None:
|
|
101
|
+
raise ValueError("Expression needs a determinable name or alias.")
|
|
102
|
+
names.append(output_name)
|
|
103
|
+
if len(names) != len(set(names)):
|
|
104
|
+
raise ValueError("Expressions have duplicate output names.")
|
|
105
|
+
if drop is not None and (
|
|
106
|
+
isinstance(drop, str)
|
|
107
|
+
or not isinstance(drop, Sequence)
|
|
108
|
+
or any(not isinstance(col, str) or not col for col in drop)
|
|
109
|
+
):
|
|
110
|
+
raise TypeError("drop must be a sequence of nonempty column names.")
|
|
111
|
+
if name is not None and (not isinstance(name, str) or not name):
|
|
112
|
+
raise ValueError("name must be a nonempty string.")
|
|
113
|
+
self.expressions: tuple[pl.Expr, ...] = canonical
|
|
114
|
+
self.drop: tuple[str, ...] = tuple(drop or ())
|
|
115
|
+
self.name: str | None = name
|
|
116
|
+
|
|
117
|
+
def __repr__(self) -> str:
|
|
118
|
+
"""Return the canonical specification representation."""
|
|
119
|
+
return (
|
|
120
|
+
f"FeatureSpec(expressions={self.expressions!r}, "
|
|
121
|
+
f"drop={self.drop!r}, name={self.name!r})"
|
|
122
|
+
)
|
polars_learn/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Learned schema validation and explicit, predictable type normalisation."""
|
|
2
|
+
|
|
3
|
+
from typing import Self
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import polars as pl
|
|
7
|
+
from sklearn.utils.validation import check_is_fitted # pyright: ignore[reportUnknownVariableType]
|
|
8
|
+
|
|
9
|
+
from ._utils import NativeTransformer, to_polars
|
|
10
|
+
from .exceptions import SchemaValidationError
|
|
11
|
+
from .types import ExtraColumnPolicy, Schema, ValidationMode
|
|
12
|
+
|
|
13
|
+
_INTEGER_RANGES: dict[pl.DataType | type[pl.DataType], tuple[int, int]] = {
|
|
14
|
+
pl.Int8: (-(2**7), 2**7 - 1),
|
|
15
|
+
pl.Int16: (-(2**15), 2**15 - 1),
|
|
16
|
+
pl.Int32: (-(2**31), 2**31 - 1),
|
|
17
|
+
pl.Int64: (-(2**63), 2**63 - 1),
|
|
18
|
+
pl.UInt8: (0, 2**8 - 1),
|
|
19
|
+
pl.UInt16: (0, 2**16 - 1),
|
|
20
|
+
pl.UInt32: (0, 2**32 - 1),
|
|
21
|
+
pl.UInt64: (0, 2**64 - 1),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _safe_widen(series: pl.Series, target: pl.DataType) -> bool:
|
|
26
|
+
"""Return whether this series satisfies the lossless widening policy."""
|
|
27
|
+
source = series.dtype
|
|
28
|
+
if source in _INTEGER_RANGES and target in _INTEGER_RANGES:
|
|
29
|
+
source_min, source_max = _INTEGER_RANGES[source]
|
|
30
|
+
target_min, target_max = _INTEGER_RANGES[target]
|
|
31
|
+
return target_min <= source_min and source_max <= target_max
|
|
32
|
+
if source == pl.Float32 and target == pl.Float64:
|
|
33
|
+
return True
|
|
34
|
+
if source in _INTEGER_RANGES and target == pl.Float64:
|
|
35
|
+
try:
|
|
36
|
+
restored = series.cast(pl.Float64).cast(source, strict=True)
|
|
37
|
+
return bool((series == restored).fill_null(True).all())
|
|
38
|
+
except pl.exceptions.PolarsError:
|
|
39
|
+
return False
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SchemaValidator(NativeTransformer, auto_wrap_output_keys=None):
|
|
44
|
+
"""Learn an input schema and validate subsequent Polars DataFrames.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
mode : {"strict", "semi-strict", "permissive"} or None, default=None
|
|
49
|
+
Validation policy. ``None`` resolves to strict mode unless ``strict``
|
|
50
|
+
is explicitly false. Strict mode requires identical dtypes. Semi-strict
|
|
51
|
+
allows only the documented lossless widening policy. Permissive mode
|
|
52
|
+
attempts strict Polars casts, which may be lossy but raise on failure.
|
|
53
|
+
allow_extra_columns : bool, default=False
|
|
54
|
+
Allow columns not present during fitting. Missing required columns
|
|
55
|
+
always raise, regardless of the validation mode.
|
|
56
|
+
enforce_column_order : bool, default=True
|
|
57
|
+
Require the relative order of required columns to match training.
|
|
58
|
+
If false, output uses fitted column order followed by preserved extras.
|
|
59
|
+
extra_columns : {"preserve", "drop"}, default="preserve"
|
|
60
|
+
Treatment of permitted extras. This option does not itself permit
|
|
61
|
+
extra columns; ``allow_extra_columns`` must also be true.
|
|
62
|
+
convert_pandas : bool, default=False
|
|
63
|
+
Allow pandas DataFrames and convert them to Polars. Requires pandas;
|
|
64
|
+
some dtypes also require PyArrow.
|
|
65
|
+
warn_on_conversion : bool, default=True
|
|
66
|
+
Emit ``PandasConversionWarning`` when converting pandas input.
|
|
67
|
+
strict : bool or None, default=None
|
|
68
|
+
Shorthand for strict mode when true or permissive mode when false.
|
|
69
|
+
Conflicting explicit ``mode`` and ``strict`` values raise ``ValueError``.
|
|
70
|
+
|
|
71
|
+
Attributes
|
|
72
|
+
----------
|
|
73
|
+
schema_ : dict of str to polars.DataType
|
|
74
|
+
Learned required columns and dtypes, in training order.
|
|
75
|
+
n_features_in_ : int
|
|
76
|
+
Number of training columns.
|
|
77
|
+
feature_names_in_ : ndarray of shape (n_features_in_,), dtype=object
|
|
78
|
+
Required input names in training order.
|
|
79
|
+
output_features_ : ndarray of shape (n_features_in_,), dtype=object
|
|
80
|
+
Fitted required names. Inference-only extras are not included.
|
|
81
|
+
|
|
82
|
+
See Also
|
|
83
|
+
--------
|
|
84
|
+
FeatureEngineer : Engineer features after validating raw inputs.
|
|
85
|
+
|
|
86
|
+
Notes
|
|
87
|
+
-----
|
|
88
|
+
Semi-strict allows integer widening when the source dtype's entire range
|
|
89
|
+
fits inside the target dtype, Float32 to Float64, and integer to Float64
|
|
90
|
+
only when every non-null value round-trips exactly. It rejects string to
|
|
91
|
+
numeric conversion, narrowing, and Null dtype to another dtype. Null values
|
|
92
|
+
in compatible columns are preserved; nullability constraints are not learned.
|
|
93
|
+
Fit always learns a fresh schema; transform always validates against it.
|
|
94
|
+
Input data and schemas owned by other transformers are never mutated.
|
|
95
|
+
|
|
96
|
+
Examples
|
|
97
|
+
--------
|
|
98
|
+
>>> import polars as pl
|
|
99
|
+
>>> from polars_learn import SchemaValidator
|
|
100
|
+
>>> train = pl.DataFrame({"x": pl.Series([1], dtype=pl.Int64)})
|
|
101
|
+
>>> test = pl.DataFrame({"x": pl.Series([2], dtype=pl.Int32)})
|
|
102
|
+
>>> validator = SchemaValidator(mode="semi-strict").fit(train)
|
|
103
|
+
>>> validator.transform(test).schema["x"]
|
|
104
|
+
Int64
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
mode: ValidationMode | None = None,
|
|
110
|
+
*,
|
|
111
|
+
allow_extra_columns: bool = False,
|
|
112
|
+
enforce_column_order: bool = True,
|
|
113
|
+
extra_columns: ExtraColumnPolicy = "preserve",
|
|
114
|
+
convert_pandas: bool = False,
|
|
115
|
+
warn_on_conversion: bool = True,
|
|
116
|
+
strict: bool | None = None,
|
|
117
|
+
) -> None:
|
|
118
|
+
self.mode = mode
|
|
119
|
+
self.allow_extra_columns = allow_extra_columns
|
|
120
|
+
self.enforce_column_order = enforce_column_order
|
|
121
|
+
self.extra_columns = extra_columns
|
|
122
|
+
self.convert_pandas = convert_pandas
|
|
123
|
+
self.warn_on_conversion = warn_on_conversion
|
|
124
|
+
self.strict = strict
|
|
125
|
+
|
|
126
|
+
def _mode(self) -> ValidationMode:
|
|
127
|
+
if self.mode not in (None, "strict", "semi-strict", "permissive"):
|
|
128
|
+
raise ValueError("mode must be 'strict', 'semi-strict', or 'permissive'.")
|
|
129
|
+
if self.strict is not None and not isinstance(self.strict, bool):
|
|
130
|
+
raise TypeError("strict must be a bool or None.")
|
|
131
|
+
legacy = "strict" if self.strict is not False else "permissive"
|
|
132
|
+
if self.strict is not None and self.mode is not None and self.mode != legacy:
|
|
133
|
+
raise ValueError("strict and mode specify conflicting validation modes.")
|
|
134
|
+
if self.extra_columns not in ("preserve", "drop"):
|
|
135
|
+
raise ValueError("extra_columns must be 'preserve' or 'drop'.")
|
|
136
|
+
return self.mode or legacy
|
|
137
|
+
|
|
138
|
+
def _input(self, X: object) -> pl.DataFrame:
|
|
139
|
+
return to_polars(
|
|
140
|
+
X,
|
|
141
|
+
convert_pandas=self.convert_pandas,
|
|
142
|
+
warn_on_conversion=self.warn_on_conversion,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def fit(self, X: object, y: object = None) -> Self:
|
|
146
|
+
"""Learn column names, order, and dtypes without changing the input.
|
|
147
|
+
|
|
148
|
+
Parameters
|
|
149
|
+
----------
|
|
150
|
+
X : polars.DataFrame or pandas.DataFrame of shape (n_samples, n_features)
|
|
151
|
+
Training data whose schema becomes the required schema. Pandas
|
|
152
|
+
input requires ``convert_pandas=True``.
|
|
153
|
+
y : object, default=None
|
|
154
|
+
Ignored. Present for scikit-learn estimator compatibility.
|
|
155
|
+
|
|
156
|
+
Returns
|
|
157
|
+
-------
|
|
158
|
+
self : SchemaValidator
|
|
159
|
+
Fitted validator with a newly learned schema.
|
|
160
|
+
|
|
161
|
+
Raises
|
|
162
|
+
------
|
|
163
|
+
TypeError
|
|
164
|
+
If the input type is unsupported or pandas conversion is disabled.
|
|
165
|
+
ValueError
|
|
166
|
+
If validation configuration is invalid or conflicting.
|
|
167
|
+
"""
|
|
168
|
+
self._mode()
|
|
169
|
+
frame = self._input(X)
|
|
170
|
+
self.schema_: Schema = dict(frame.schema)
|
|
171
|
+
self.feature_names_in_ = np.asarray(frame.columns, dtype=object)
|
|
172
|
+
self.n_features_in_ = frame.width
|
|
173
|
+
self.output_features_ = self.feature_names_in_.copy()
|
|
174
|
+
return self
|
|
175
|
+
|
|
176
|
+
def fit_transform(
|
|
177
|
+
self, X: object, y: object = None, **fit_params: object
|
|
178
|
+
) -> pl.DataFrame:
|
|
179
|
+
"""Fit to training data and return its transformed representation.
|
|
180
|
+
|
|
181
|
+
Parameters
|
|
182
|
+
----------
|
|
183
|
+
X : polars.DataFrame or pandas.DataFrame of shape (n_samples, n_features)
|
|
184
|
+
Training data. Pandas input requires ``convert_pandas=True``.
|
|
185
|
+
y : object, default=None
|
|
186
|
+
Ignored. Present for scikit-learn estimator compatibility.
|
|
187
|
+
**fit_params : dict
|
|
188
|
+
Accepted for the scikit-learn method signature. Additional fit
|
|
189
|
+
parameters are unsupported and raise ``TypeError`` when supplied.
|
|
190
|
+
|
|
191
|
+
Returns
|
|
192
|
+
-------
|
|
193
|
+
X_transformed : polars.DataFrame of shape (n_samples, n_features_out)
|
|
194
|
+
Result of fitting this transformer and transforming the same data.
|
|
195
|
+
|
|
196
|
+
See Also
|
|
197
|
+
--------
|
|
198
|
+
fit : Learn the fitted schema and feature names.
|
|
199
|
+
transform : Apply the fitted transformer and its validation policies.
|
|
200
|
+
"""
|
|
201
|
+
if fit_params:
|
|
202
|
+
raise TypeError(
|
|
203
|
+
f"Additional fit parameters are unsupported: {sorted(fit_params)}."
|
|
204
|
+
)
|
|
205
|
+
return self.fit(X, y).transform(X)
|
|
206
|
+
|
|
207
|
+
def transform(self, X: object) -> pl.DataFrame:
|
|
208
|
+
"""Validate the learned schema and normalise permitted differences.
|
|
209
|
+
|
|
210
|
+
Parameters
|
|
211
|
+
----------
|
|
212
|
+
X : polars.DataFrame or pandas.DataFrame of shape (n_samples, n_columns)
|
|
213
|
+
Input containing all fitted required columns. Additional columns
|
|
214
|
+
must be explicitly permitted. Pandas requires conversion enabled.
|
|
215
|
+
|
|
216
|
+
Returns
|
|
217
|
+
-------
|
|
218
|
+
X_validated : polars.DataFrame of shape (n_samples, n_output_columns)
|
|
219
|
+
Validated input with any configured casts, ordering, and extra
|
|
220
|
+
column policy applied. The original input is never mutated.
|
|
221
|
+
|
|
222
|
+
Raises
|
|
223
|
+
------
|
|
224
|
+
sklearn.exceptions.NotFittedError
|
|
225
|
+
If ``fit`` has not completed successfully.
|
|
226
|
+
TypeError
|
|
227
|
+
If the input is unsupported or pandas conversion is disabled.
|
|
228
|
+
SchemaValidationError
|
|
229
|
+
If required columns are missing, extras or order are disallowed,
|
|
230
|
+
dtypes are incompatible, or a cast fails. Multiple mismatches are
|
|
231
|
+
reported together, with cast exceptions preserved as the cause.
|
|
232
|
+
"""
|
|
233
|
+
check_is_fitted(self, "schema_")
|
|
234
|
+
mode = self._mode()
|
|
235
|
+
frame = self._input(X)
|
|
236
|
+
expected_names = list(self.schema_)
|
|
237
|
+
missing = [col for col in expected_names if col not in frame.columns]
|
|
238
|
+
extras = [col for col in frame.columns if col not in self.schema_]
|
|
239
|
+
issues: list[str] = []
|
|
240
|
+
if missing:
|
|
241
|
+
issues.append(f"Missing required columns: {missing}.")
|
|
242
|
+
if extras and not self.allow_extra_columns:
|
|
243
|
+
issues.append(f"Unexpected columns: {extras}.")
|
|
244
|
+
present = [col for col in frame.columns if col in self.schema_]
|
|
245
|
+
if self.enforce_column_order and not missing and present != expected_names:
|
|
246
|
+
issues.append(
|
|
247
|
+
f"Column order differs. Expected {expected_names}, received {present}."
|
|
248
|
+
)
|
|
249
|
+
conversions: list[pl.Series] = []
|
|
250
|
+
cause: Exception | None = None
|
|
251
|
+
for column, target in self.schema_.items():
|
|
252
|
+
if column in missing:
|
|
253
|
+
continue
|
|
254
|
+
series = frame[column]
|
|
255
|
+
source = series.dtype
|
|
256
|
+
if source == target:
|
|
257
|
+
continue
|
|
258
|
+
detail = (
|
|
259
|
+
f"Column {column!r} has an incompatible type. "
|
|
260
|
+
f"Expected: {target}; Received: {source}; Validation mode: {mode}."
|
|
261
|
+
)
|
|
262
|
+
if mode == "strict" or (
|
|
263
|
+
mode == "semi-strict" and not _safe_widen(series, target)
|
|
264
|
+
):
|
|
265
|
+
issues.append(detail)
|
|
266
|
+
continue
|
|
267
|
+
try:
|
|
268
|
+
conversions.append(series.cast(target, strict=True))
|
|
269
|
+
except pl.exceptions.PolarsError as exc:
|
|
270
|
+
cause = exc
|
|
271
|
+
issues.append(f"{detail} Cast failed: {exc}")
|
|
272
|
+
if issues:
|
|
273
|
+
raise SchemaValidationError("\n".join(issues)) from cause
|
|
274
|
+
result = frame.with_columns(conversions) if conversions else frame
|
|
275
|
+
if self.extra_columns == "drop" or not self.enforce_column_order:
|
|
276
|
+
columns = expected_names
|
|
277
|
+
if self.extra_columns == "preserve":
|
|
278
|
+
columns = columns + extras
|
|
279
|
+
result = result.select(columns)
|
|
280
|
+
return result
|
polars_learn/types.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Shared public type aliases."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from typing import Literal, TypeAlias
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import polars as pl
|
|
8
|
+
from numpy.typing import NDArray
|
|
9
|
+
|
|
10
|
+
ValidationMode: TypeAlias = Literal["strict", "semi-strict", "permissive"]
|
|
11
|
+
ExtraColumnPolicy: TypeAlias = Literal["preserve", "drop"]
|
|
12
|
+
Schema: TypeAlias = dict[str, pl.DataType]
|
|
13
|
+
|
|
14
|
+
OutputMode: TypeAlias = Literal["default", "polars"]
|
|
15
|
+
FeatureNames: TypeAlias = Sequence[str] | NDArray[np.str_] | NDArray[np.object_]
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: polars-learn
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Native Polars feature engineering and schema validation for scikit-learn
|
|
5
|
+
Author: Jack
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: numpy>=1.24
|
|
10
|
+
Requires-Dist: polars>=1.20
|
|
11
|
+
Requires-Dist: scikit-learn>=1.6
|
|
12
|
+
Provides-Extra: pandas
|
|
13
|
+
Requires-Dist: pandas>=2; extra == 'pandas'
|
|
14
|
+
Requires-Dist: pyarrow>=14; extra == 'pandas'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# polars-learn
|
|
18
|
+
|
|
19
|
+
High-performance feature engineering for scikit-learn, powered by Polars.
|
|
20
|
+
|
|
21
|
+
## Features
|
|
22
|
+
|
|
23
|
+
- Composable native Polars expressions, evaluated in dependency order.
|
|
24
|
+
- Learned schemas with strict, semi-strict, and permissive validation.
|
|
25
|
+
- scikit-learn estimators with cloning, parameters, and feature names.
|
|
26
|
+
- Native Polars output and optional, explicit pandas conversion.
|
|
27
|
+
- Python 3.11+, Polars 1.20+, and scikit-learn 1.6+.
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
uv add polars-learn
|
|
33
|
+
# From a local checkout:
|
|
34
|
+
uv pip install .
|
|
35
|
+
# Optional pandas and PyArrow support:
|
|
36
|
+
uv pip install '.[pandas]'
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quick Start
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import polars as pl
|
|
43
|
+
from polars_learn import FeatureEngineer, FeatureSpec, SchemaValidator
|
|
44
|
+
|
|
45
|
+
X = pl.DataFrame({"price": [10.0, 20.0, 30.0], "quantity": [2, 3, 4]})
|
|
46
|
+
validator = SchemaValidator(mode="strict").fit(X)
|
|
47
|
+
validated = validator.transform(X)
|
|
48
|
+
|
|
49
|
+
engineer = FeatureEngineer(
|
|
50
|
+
[
|
|
51
|
+
FeatureSpec(pl.col("price") * pl.col("quantity"), alias="notional"),
|
|
52
|
+
FeatureSpec(
|
|
53
|
+
pl.col("notional").log1p(),
|
|
54
|
+
alias="log_notional",
|
|
55
|
+
drop=["quantity"],
|
|
56
|
+
),
|
|
57
|
+
]
|
|
58
|
+
)
|
|
59
|
+
result = engineer.fit_transform(validated)
|
|
60
|
+
print(result)
|
|
61
|
+
print(engineer.get_feature_names_out())
|
|
62
|
+
# ['price' 'notional' 'log_notional']
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Feature Engineering
|
|
66
|
+
|
|
67
|
+
`FeatureSpec` accepts exactly one of `expression` (one `pl.Expr`) or `expressions` (a nonempty sequence of expressions). It stores a single canonical expression tuple. Names follow Polars conventions: an existing alias or a native output name needs no additional alias. An explicit `alias` overrides the name of one output; it is rejected for multiple outputs. Selectors and expression expansion are supported, with their output names resolved against the frame.
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
spec = FeatureSpec(
|
|
71
|
+
expressions=[
|
|
72
|
+
(pl.col("ask") - pl.col("bid")).alias("spread"),
|
|
73
|
+
((pl.col("ask") + pl.col("bid")) / 2).alias("mid"),
|
|
74
|
+
],
|
|
75
|
+
drop=["ask", "bid"],
|
|
76
|
+
name="quote_features",
|
|
77
|
+
)
|
|
78
|
+
quotes = pl.DataFrame({"ask": [11.0], "bid": [9.0]})
|
|
79
|
+
print(FeatureEngineer([spec]).fit_transform(quotes))
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Expressions within one specification are evaluated together and cannot reference new columns created by siblings. Separate specifications execute sequentially; later ones can use earlier outputs. Drops happen after each specification. Missing expression inputs, missing drop columns, and duplicate output names raise `FeatureEngineeringError` (duplicate names detectable at construction raise `ValueError`). Overwriting existing columns is allowed and keeps Polars' column position. For example, `FeatureSpec(pl.col("price") + 1)` replaces `price`.
|
|
83
|
+
|
|
84
|
+
`fit` executes the training transformation to validate expressions and learn output names and dtypes. `transform` requires the fitted input column names and order and the exact fitted output schema. Input dtype differences are acceptable only when the resulting schema remains identical; use `SchemaValidator` first for explicit input type policies. Empty feature lists are identity transforms. The public API accepts eager DataFrames; internally, lazy stages preserve sequential semantics and collect once per execution. Expressions are stateless; training transformations do not learn statistics. Use deterministic expressions to keep training and inference consistent.
|
|
85
|
+
|
|
86
|
+
## Schema Validation
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
validator = SchemaValidator(
|
|
90
|
+
mode="semi-strict",
|
|
91
|
+
allow_extra_columns=False,
|
|
92
|
+
enforce_column_order=True,
|
|
93
|
+
)
|
|
94
|
+
validator.fit(X) # Always learns the raw input schema
|
|
95
|
+
print(validator.schema_)
|
|
96
|
+
validated = validator.transform(X) # Always validates against that schema
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
| Mode | Policy for differing dtypes |
|
|
100
|
+
| ------------------ | ------------------------------------------------------- |
|
|
101
|
+
| `strict` (default) | Reject every dtype difference. |
|
|
102
|
+
| `semi-strict` | Allow only the explicit lossless widening policy below. |
|
|
103
|
+
| `permissive` | Use Polars `cast(strict=True)` and report failures. |
|
|
104
|
+
|
|
105
|
+
Semi-strict allows integer widening when the entire source dtype's range fits inside the target integer dtype (including compatible unsigned-to-signed widening), Float32 → Float64, and integer → Float64 only when **every non-null value round-trips exactly**. For example, Int64 `2**53` and `2**54` are accepted, but `2**53 + 1` is rejected. It rejects narrowing, strings to numbers, and Null dtype to a different dtype. Null values in already compatible columns are preserved; nullability constraints are not part of this version.
|
|
106
|
+
|
|
107
|
+
Permissive casts may be lossy according to Polars semantics, such as floats to integers. Failed casts never silently replace invalid values with nulls. Missing required columns always fail; multiple schema mismatches are reported in `SchemaValidationError`. Strict mode does not cast or rename columns.
|
|
108
|
+
|
|
109
|
+
Extra columns fail by default. Set `allow_extra_columns=True` to preserve them, or also set `extra_columns="drop"` to explicitly remove them. Column order checks compare the relative order of required columns; permitted extras may appear anywhere. With `enforce_column_order=False`, output is normalised to fitted column order, followed by preserved extras in their input order. `get_feature_names_out()` describes fitted required columns; inference-only extras are not included. Avoid preserving inference-only extras before a fixed-width downstream estimator or `FeatureEngineer`.
|
|
110
|
+
|
|
111
|
+
The shorthand `strict=True` means strict mode, and `strict=False` means permissive mode. Conflicting `strict` and `mode` arguments are rejected. Schemas learned by the validator and feature engineer are independent.
|
|
112
|
+
|
|
113
|
+
## scikit-learn Pipelines
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
import numpy as np
|
|
117
|
+
from sklearn.ensemble import RandomForestRegressor
|
|
118
|
+
from sklearn.pipeline import Pipeline
|
|
119
|
+
from sklearn.preprocessing import StandardScaler
|
|
120
|
+
|
|
121
|
+
pipeline = Pipeline(
|
|
122
|
+
[
|
|
123
|
+
("schema", SchemaValidator(mode="semi-strict")),
|
|
124
|
+
("features", engineer),
|
|
125
|
+
("scale", StandardScaler().set_output(transform="polars")),
|
|
126
|
+
("model", RandomForestRegressor(n_estimators=20, random_state=0)),
|
|
127
|
+
]
|
|
128
|
+
)
|
|
129
|
+
pipeline.fit(X, np.array([1.0, 2.0, 3.0]))
|
|
130
|
+
print(pipeline.predict(X))
|
|
131
|
+
print(pipeline[:-1].get_feature_names_out())
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
These transformers implement `fit`, `transform`, `fit_transform`, `get_params`, `set_params`, and `get_feature_names_out`. Fitted attributes include `feature_names_in_`, `n_features_in_`, and `output_features_`. Cloning discards fitted state. Supplied `input_features` must exactly match fitted input names.
|
|
135
|
+
|
|
136
|
+
Data stays in Polars between our transformers. Both support `set_output(transform="default")` and `set_output(transform="polars")`, which return native Polars frames. They reject `"pandas"` output and deliberately bypass scikit-learn's automatic output wrapping, including global output settings, to preserve native names and allowed extras. No automatic NumPy conversion occurs. Downstream estimator support for Polars depends on the estimator and installed scikit-learn version; arbitrary string columns do not work with random forests.
|
|
137
|
+
|
|
138
|
+
Tests run applicable scikit-learn constructor, parameter, and cloning checks. Generic checks requiring NumPy array input do not apply to this deliberately DataFrame-only API. No LazyFrame, sparse-array, or NumPy input is accepted.
|
|
139
|
+
|
|
140
|
+
## Optional Pandas Support
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
uv sync --extra pandas
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
# Requires the pandas extra; both transformers expose these options.
|
|
148
|
+
import pandas as pd
|
|
149
|
+
|
|
150
|
+
pandas_X = pd.DataFrame({"price": [10.0, 20.0], "quantity": [2, 3]})
|
|
151
|
+
validator = SchemaValidator(convert_pandas=True, warn_on_conversion=False)
|
|
152
|
+
polars_X = validator.fit_transform(pandas_X)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Pandas input is rejected unless `convert_pandas=True`. Conversion returns Polars and emits `PandasConversionWarning` about memory and performance overhead unless `warn_on_conversion=False`. Imports are lazy; core functionality works without pandas or PyArrow. The extra includes PyArrow because nullable, extension, and other non-NumPy-backed pandas dtypes can require it for `pl.from_pandas`. Conversion follows Polars conventions (including NaN-to-null and ignoring the pandas index). Unsupported conversion dtypes raise rather than falling back to row-wise operations.
|
|
156
|
+
|
|
157
|
+
## Development
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
uv sync
|
|
161
|
+
uv sync --extra pandas # Optional interoperability test coverage
|
|
162
|
+
uv run pytest
|
|
163
|
+
uv run ruff check .
|
|
164
|
+
uv run ruff format --check .
|
|
165
|
+
uv run pyright
|
|
166
|
+
uv build
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`uv.lock` pins reproducible development dependencies. Pyright includes its Node runtime through the development extra, so no system Node installation is needed. Pandas tests use `pytest.importorskip("pandas")`; core tests also run without the optional extra. The package ships `py.typed` for downstream typing tools.
|
|
170
|
+
|
|
171
|
+
Library requirements use minimum versions without speculative upper bounds. Add an upper bound or exclude a release only when a known incompatibility requires it; the lockfile pins the development environment without constraining downstream applications.
|
|
172
|
+
|
|
173
|
+
### Releases
|
|
174
|
+
|
|
175
|
+
GitHub Actions follows the same release setup as Conformity. Configure the repository or `pypi` environment secret `PYPI_API_TOKEN` with a PyPI upload token for `polars-learn`. The publishing job uses the `pypi` environment. No API token belongs in this repository.
|
|
176
|
+
|
|
177
|
+
Update `project.version` in `pyproject.toml` and `__version__` in `src/polars_learn/__init__.py`, run `uv lock`, and push the changes. Then run **Create GitHub Release** from the Actions tab. It checks tests, linting, formatting, and types; rejects an existing `vX.Y.Z` tag; builds and validates distributions; creates a GitHub release with those files; and passes the same artifacts directly to **Upload Python Package** for PyPI publishing. The reusable workflow call is necessary because a release created with GitHub's workflow token does not trigger another release workflow.
|
|
178
|
+
|
|
179
|
+
Alternatively, publish a GitHub release tagged `vX.Y.Z` matching `project.version`. **Upload Python Package** checks the package, builds distributions, and publishes them automatically. Pushes and pull requests run **Tests** on Python 3.11 and 3.12, both with and without the pandas extra. Creating or publishing a release uploads that version to PyPI; ordinary pushes do not publish packages.
|
|
180
|
+
|
|
181
|
+
## License
|
|
182
|
+
|
|
183
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
polars_learn/__init__.py,sha256=iteuOV22jzl0YR6_LVenPyEvCssDHIex2xcisJta6sk,568
|
|
2
|
+
polars_learn/_utils.py,sha256=SdVXWmRNWHV-NCigjy3IlPDfvQezolr8TMHMY_O0m14,5380
|
|
3
|
+
polars_learn/exceptions.py,sha256=XaO_9T_QIt6_tOQLjTcdFxyL8oyjgLQHcCfymtJMwnI,416
|
|
4
|
+
polars_learn/feature_engineering.py,sha256=IjtFpkTqOQbEaZ8iJrjBjfR_dj4V20f4AtihQI7f9SE,9048
|
|
5
|
+
polars_learn/feature_spec.py,sha256=mQUwrx6UUtoWWqgrakz38GVlKFBCdiwJ9vNzCtYPQyo,5075
|
|
6
|
+
polars_learn/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
polars_learn/schema_validation.py,sha256=cw6sCFfNHjsFElSbaGqY7X-Jm95JUYFi0Zwe7t7foS4,11951
|
|
8
|
+
polars_learn/types.py,sha256=WLxyQouJhhF8MGDehZWuSoSosWNj8feHKHHYuOwdW10,496
|
|
9
|
+
polars_learn-0.1.0.dist-info/METADATA,sha256=VOzCRXCqSn4LrLJ1OM3SW2I1V02geQB-7aVEoumLVRk,10792
|
|
10
|
+
polars_learn-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
11
|
+
polars_learn-0.1.0.dist-info/licenses/LICENSE,sha256=udA5kf7HpCT8TszrVTQLu2HDngsJlAq3GVMoFu0_wgc,1061
|
|
12
|
+
polars_learn-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jack
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|