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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.