Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
45 changes: 33 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,65 @@ 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")
# TODO: remove when old names are dropped in 0.7.0
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