Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,15 @@ Changes
:func:`datasets.get_ken_table_aliases`, and :func:`datasets.get_ken_types` will be
removed in the next release of skrub.
:pr:`1546` by :user:`Vincent Maladiere <Vincent-Maladiere>`.

- Improved error messages when a DataOp is being sent to dispatched functions.
:pr:`1607` by :user:`Riccardo Cappuzzo<rcap107>`.
- The accepted values for the parameter ``how`` of :meth:`DataOp.skb.apply` have
changed. The new values are ``"auto"`` (unchanged), ``"cols"`` to wrap the
transformer in :class:`ApplyToCols`, ``"frame"`` to wrap the transformer in
:class:`ApplyToFrame`, or ``"no_wrap"`` for no wrapping. The old values are
deprecated and will result in an error in a future release.
:pr:`1628` by :user:`Jérôme Dockès <jeromedockes>`.


Bugfixes
--------
Expand Down
35 changes: 29 additions & 6 deletions skrub/_data_ops/_data_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -722,8 +722,8 @@ def _check_wrap_params(cols, how, allow_reject, reason):
msg = None
if not isinstance(cols, type(s.all())):
msg = f"`cols` must be `all()` (the default) when {reason}"
elif how not in ["auto", "full_frame"]:
msg = f"`how` must be 'auto' (the default) or 'full_frame' when {reason}"
elif how not in ["auto", "no_wrap"]:
msg = f"`how` must be 'auto' (the default) or 'no_wrap' when {reason}"
elif allow_reject:
msg = f"`allow_reject` must be False (the default) when {reason}"
if msg is not None:
Expand Down Expand Up @@ -754,11 +754,34 @@ def _check_estimator_type(estimator):
)


def _check_apply_how(how):
valid = ["auto", "cols", "frame", "no_wrap"]
if how in valid:
return how

# TODO remove when the old names are completely dropped in 0.7.0
translate = {"columnwise": "cols", "sub_frame": "frame", "full_frame": "no_wrap"}
if how in translate:
new = translate[how]
warnings.warn(
(
f"{how!r} has been renamed to {new!r}: use .skb.apply(how={new!r})"
" instead."
),
FutureWarning,
)
return new

raise ValueError(f"`how` must be one of {valid}. Got: {how!r}")


def _wrap_estimator(estimator, cols, how, allow_reject, X):
"""
Wrap the estimator passed to .skb.apply in ApplyToCols or ApplyToFrame if
needed.
"""
how = _check_apply_how(how)

if estimator in [None, "passthrough"]:
estimator = PassThrough()

Expand All @@ -767,16 +790,16 @@ def _wrap_estimator(estimator, cols, how, allow_reject, X):
def _check(reason):
_check_wrap_params(cols, how, allow_reject, reason)

if how == "full_frame":
_check("`how` is 'full_frame'")
if how == "no_wrap":
_check("`how` is 'no_wrap'")
return estimator
if hasattr(estimator, "predict") or not hasattr(estimator, "transform"):
_check("`estimator` is a predictor (not a transformer)")
return estimator
if not sbd.is_dataframe(X):
_check("the input is not a DataFrame")
return estimator
columnwise = {"auto": "auto", "columnwise": True, "sub_frame": False}[how]
columnwise = {"auto": "auto", "cols": True, "frame": False}[how]
return wrap_transformer(
estimator, cols, allow_reject=allow_reject, columnwise=columnwise
)
Expand Down Expand Up @@ -1186,7 +1209,7 @@ def eval(self, *, mode, environment):


def _check_column_names(X):
# NOTE: could allow int column names when how='full_frame', prob. not worth
# NOTE: could allow int column names when how='no_wrap', prob. not worth
# the added complexity.
#
# TODO: maybe also forbid duplicates? use a reduced version of
Expand Down
25 changes: 13 additions & 12 deletions skrub/_data_ops/_estimator.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,15 +249,15 @@ def find_fitted_estimator(self, name):
>>> learner.find_fitted_estimator("classifier")
DummyClassifier()

Depending on the parameters passed to ``skb.apply()``, the estimator we provide
can be wrapped in a skrub transformer that applies it to several columns in the
input, or to a subset of the columns in a dataframe. In other cases it may be
applied without any wrapping. We provide examples for those 3 different cases
below.
Depending on the parameters passed to :meth:`DataOp.skb.apply`, the
estimator we provide can be wrapped in a skrub transformer that applies
it to several columns in the input, or to a subset of the columns in a
dataframe. In other cases it may be applied without any wrapping. We
provide examples for those 3 different cases below.

Case 1: the ``StringEncoder`` is a skrub single-column transformer: it
transforms a single column. In the learner it gets wrapped in a
``skrub.ApplyToCols`` which independently fits a separate instance of the
:class:`ApplyToCols` which independently fits a separate instance of the
``StringEncoder`` to each of the columns it transforms (in this case there is
only one column, ``'product'``). The individual transformers can be found in the
fitted attribute ``transformers_`` which maps column names to the corresponding
Expand All @@ -269,13 +269,13 @@ def find_fitted_estimator(self, name):
>>> encoder.transformers_['product'].vectorizer_.vocabulary_
{' pe': 2, 'pen': 12, 'en ': 8, ' pen': 3, 'pen ': 13, ' cu': 0, 'cup': 6, 'up ': 18, ' cup': 1, 'cup ': 7, ' sp': 4, 'spo': 16, 'poo': 14, 'oon': 10, 'on ': 9, ' spo': 5, 'spoo': 17, 'poon': 15, 'oon ': 11}

This case (wrapping in ``ApplyToCols``) happens when the estimator is a skrub
This case (wrapping in :class:`ApplyToCols`) happens when the estimator is a skrub
single-column transformer (it has a ``__single_column_transformer__``
attribute), we pass ``.skb.apply(how='columnwise')`` or we pass
attribute), we pass ``.skb.apply(how='cols')`` or we pass
``.skb.apply(allow_reject=True)``.

Case 2: the ``PCA`` is a regular scikit-learn transformer. In the learner it
gets wrapped in a ``skrub.ApplyToFrame`` which applies it to the subset of columns
gets wrapped in a :class:`ApplyToFrame` which applies it to the subset of columns
in the dataframe selected by the ``cols`` argument passed to ``.skb.apply()``.
The fitted ``PCA`` can be found in the fitted attribute ``transformer_``.

Expand All @@ -287,8 +287,9 @@ def find_fitted_estimator(self, name):
>>> pca.transformer_.mean_
array([2020., 4., 4.], dtype=float32)

This case (wrapping in ``ApplyToFrame``) happens when the estimator is a
scikit-learn transformer but not a single-column transformer.
This case (wrapping in :class:`ApplyToFrame`) happens when the estimator is a
scikit-learn transformer but not a single-column transformer, or we
pass ``.skb.apply(how='frame')``.

The ``DummyRegressor`` is a scikit-learn predictor. In the learner it gets
applied directly to the input dataframe without any wrapping.
Expand All @@ -301,7 +302,7 @@ def find_fitted_estimator(self, name):

This case (no wrapping) happens when the estimator is a scikit-learn predictor
(not a transformer), the input is not a dataframe (e.g. it is a numpy array), or
we pass ``.skb.apply(how='full_frame')``.
we pass ``.skb.apply(how='no_wrap')``.
""" # noqa: E501
node = find_node_by_name(self.data_op, name)
if node is None:
Expand Down
35 changes: 26 additions & 9 deletions skrub/_data_ops/_skrub_namespace.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,13 +143,24 @@ def apply(
_not_ be applied. The columns that are matched by ``cols`` AND not
matched by ``exclude_cols`` are transformed.

how : "auto", "columnwise", "subframe" or "full_frame", optional
The mode in which it is applied. In the vast majority of cases the
default "auto" is appropriate. "columnwise" means a separate clone
of the transformer is applied to each column. "subframe" means it
is applied to a subset of the columns, passed as a single
dataframe. "full_frame" means the whole input dataframe is passed
directly to the provided ``estimator``.
how : "auto", "cols", "frame" or "no_wrap", optional
How the estimator is applied. In most cases the default "auto"
is appropriate.
- "cols" means `estimator` is wrapped in a :class:`ApplyToCols`
transformer, which fits a separate clone of `estimator` each
column in `cols`. `estimator` must be a transformer (have a
``fit_transform`` method).
- "frame" means `estimator` is wrapped in a :class:`ApplyToFrame`
transformer, which fits a single clone of `estimator` to the
selected part of the input dataframe. `estimator` must be a
transformer.
- "no_wrap" means no wrapping, `estimator` is applied directly to
the unmodified input.
- "auto" chooses the wrapping depending on the input and estimator.
If the input is not a dataframe or the estimator is not a
transformer, the "no_wrap" strategy is chosen. Otherwise if the
estimator has a ``__single_column_transformer__`` attribute,
"cols" is chosen. Otherwise "frame" is chosen.

allow_reject : bool, optional
Whether the transformer can refuse to transform columns for which
Expand Down Expand Up @@ -181,6 +192,12 @@ def apply(
--------
skrub.DataOp.skb.make_learner :
Get a skrub learner for this DataOp.
skrub.ApplyToCols :
Transformer that applies a given transformer separately to each
selected column.
skrub.ApplyToFrame:
Transformer that applies a given transformer to part of a
dataframe.

Examples
--------
Expand Down Expand Up @@ -554,7 +571,7 @@ def select(self, cols):
2 cup 2020-04-04
3 spoon 2020-04-05
"""
return self._apply(SelectCols(cols), how="full_frame")
return self._apply(SelectCols(cols), how="no_wrap")

@check_data_op
def drop(self, cols):
Expand Down Expand Up @@ -607,7 +624,7 @@ def drop(self, cols):
2 3 5
3 4 1
"""
return self._apply(DropCols(cols), how="full_frame")
return self._apply(DropCols(cols), how="no_wrap")

@check_data_op
def concat(self, others, axis=0):
Expand Down
44 changes: 32 additions & 12 deletions skrub/_data_ops/tests/test_data_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from sklearn.model_selection import train_test_split

import skrub
from skrub import ApplyToCols
from skrub import selectors as s
from skrub._data_ops import _data_ops
from skrub._utils import PassThrough
Expand Down Expand Up @@ -329,45 +330,64 @@ class A(_data_ops.DataOpImpl):
a.skb.eval()


@pytest.mark.parametrize("why_full_frame", ["numpy", "predictor", "how"])
@pytest.mark.parametrize("why_no_wrap", ["numpy", "predictor", "how"])
@pytest.mark.parametrize("bad_param", ["cols", "how", "allow_reject"])
def test_apply_bad_params(why_full_frame, bad_param):
def test_apply_bad_params(why_no_wrap, bad_param):
# When the estimator is a predictor or the input is a numpy array (not a
# dataframe) (or how='full_frame') the estimator can only be applied to the
# dataframe) (or how='no_wrap') the estimator can only be applied to the
# full input without wrapping in ApplyToCols or ApplyToFrame. In this case
# if the user passed a parameter that would require wrapping, such as
# passing a value for `cols` that is not `all()`, or passing
# how='columnwise' or allow_reject=True, we get an error.
# how='cols' or allow_reject=True, we get an error.

if why_full_frame == bad_param == "how":
if why_no_wrap == bad_param == "how":
return
X_a, y_a = make_classification(random_state=0)
X_df = pd.DataFrame(X_a, columns=[f"col_{i}" for i in range(X_a.shape[1])])

X = skrub.X(X_a) if why_full_frame == "numpy" else skrub.X(X_df)
if why_full_frame == "predictor":
X = skrub.X(X_a) if why_no_wrap == "numpy" else skrub.X(X_df)
if why_no_wrap == "predictor":
estimator = LogisticRegression()
y = skrub.y(y_a)
else:
estimator = PassThrough()
y = None
how = "full_frame" if why_full_frame == "how" else "auto"
# X is a numpy array: how must be full_frame and selecting columns is not
how = "no_wrap" if why_no_wrap == "how" else "auto"
# X is a numpy array: how must be no_wrap and selecting columns is not
# allowed.
if bad_param == "cols":
if why_full_frame == "numpy":
if why_no_wrap == "numpy":
cols = [0, 1]
else:
cols = ["col_0", "col_1"]
else:
cols = s.all()
how = "columnwise" if bad_param == "how" else how
how = "cols" if bad_param == "how" else how
allow_reject = True if bad_param == "allow_reject" else False

with pytest.raises((ValueError, RuntimeError), match=""):
with pytest.raises(
(ValueError, RuntimeError),
match=(
r"(`cols` must be `all\(\)`|`how` must be 'auto'|`allow_reject` must be"
r" False)"
),
):
X.skb.apply(estimator, y=y, how=how, allow_reject=allow_reject, cols=cols)


def test_apply_invalid_how():
df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
X = skrub.var("X", df)
t = PassThrough()
for how in ["auto", "cols", "frame", "no_wrap"]:
assert list(X.skb.apply(t, how=how).skb.eval().columns) == ["a", "b"]
with pytest.raises(RuntimeError, match="`how` must be one of"):
X.skb.apply(t, how="bad value")
with pytest.warns(FutureWarning, match="'columnwise' has been renamed to 'cols'"):
Comment thread
rcap107 marked this conversation as resolved.
wrapper = X.skb.apply(t, how="columnwise").skb.applied_estimator.skb.eval()
assert isinstance(wrapper, ApplyToCols)


class Mul(BaseEstimator):
def __init__(self, factor=1):
self.factor = factor
Expand Down
Loading