Skip to content

Commit b29f2b8

Browse files
sanitize behavior when estimator passed to apply() is a dataop (skrub-data#1671)
Co-authored-by: Riccardo Cappuzzo <7548232+rcap107@users.noreply.github.com>
1 parent cae42e1 commit b29f2b8

4 files changed

Lines changed: 133 additions & 23 deletions

File tree

CHANGES.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,15 @@ Changes
3636

3737
Bugfixes
3838
--------
39+
- Issues occurring when :meth:`DataOp.skb.apply` was passed a DataOp as the
40+
estimator have been fixed in :pr:`1671` by :user:`Jérôme Dockès
41+
<jeromedockes>`.
3942
- :class:`TableReport` could raise an error while trying to check if Polars
4043
columns with some dtypes (lists, structs) are sorted. It would not indicate
4144
Polars columns sorted in descending order. Fixed in :pr:`1673` by
4245
:user:`Jérôme Dockès <jeromedockes>`.
4346

47+
4448
Release 0.6.2
4549
=============
4650

skrub/_data_ops/_data_ops.py

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1269,15 +1269,21 @@ class Apply(DataOpImpl):
12691269
# we do not evaluate `y`.
12701270

12711271
def eval(self, *, mode, environment):
1272-
if mode not in self.supported_modes():
1272+
# 1. Find method to call and collect the necessary arguments: X and
1273+
# possibly y and the estimator (depending on the mode).
1274+
1275+
if "fit" in mode or mode == "preview":
1276+
# we need to fit, evaluate the estimator
1277+
estimator = yield self.estimator
1278+
else:
1279+
# for other modes self.estimator_ will be used
1280+
estimator = None
1281+
if mode not in self.supported_modes(estimator):
12731282
# We are not the final estimator, e.g. mode is 'predict' and we are
12741283
# a transformer that comes before the predictor.
12751284
mode = "fit_transform" if "fit" in mode else "transform"
12761285
method_name = "fit_transform" if mode == "preview" else mode
12771286

1278-
# 1. Collect the necessary arguments: X and possibly y and the estimator
1279-
# (depending on the mode).
1280-
12811287
X = yield self.X
12821288
if ("fit" in method_name and not self.unsupervised) or method_name == "score":
12831289
y = yield self.y
@@ -1288,7 +1294,6 @@ def eval(self, *, mode, environment):
12881294
X = _check_column_names(X)
12891295

12901296
if "fit" in method_name:
1291-
estimator = yield self.estimator
12921297
cols = yield self.cols
12931298
how = yield self.how
12941299
allow_reject = yield self.allow_reject
@@ -1387,18 +1392,23 @@ def _eval_kwargs(self, method_name):
13871392
)
13881393
return kwargs
13891394

1390-
def supported_modes(self):
1395+
def supported_modes(self, estimator=None):
13911396
"""
13921397
Used by SkrubLearner and param search to decide if they have the
13931398
methods `predict`, `predict_proba` etc.
13941399
"""
13951400
modes = ["preview", "fit", "fit_transform", "transform"]
1396-
try:
1397-
estimator = self.estimator_
1398-
except AttributeError:
1399-
estimator = get_chosen_or_default(self.estimator)
1401+
if estimator is None:
1402+
try:
1403+
estimator = self.estimator_
1404+
except AttributeError:
1405+
estimator = get_chosen_or_default(self.estimator)
14001406
for name in FITTED_PREDICTOR_METHODS:
1401-
if hasattr(estimator, name):
1407+
if isinstance(estimator, DataOp) or hasattr(estimator, name):
1408+
# if estimator is a DataOp we cannot know yet if it has the
1409+
# attribute, in this case we assume it does (and risk a
1410+
# slightly worse error message when a SkrubLearner tries to use
1411+
# it if it does not).
14021412
modes.append(name)
14031413
return modes
14041414

skrub/_data_ops/_estimator.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from .. import _dataframe as sbd
1111
from .. import _join_utils
1212
from ._choosing import BaseNumericChoice, get_default
13-
from ._data_ops import Apply, check_subsampled_X_y_shape
13+
from ._data_ops import Apply, DataOp, check_subsampled_X_y_shape
1414
from ._evaluation import (
1515
choice_graph,
1616
chosen_or_default_outcomes,
@@ -409,8 +409,6 @@ def describe_params(self):
409409
)
410410

411411

412-
# Xy_pipeline because it is an actual scikit-learn pippeline rather than
413-
# a skrub learner
414412
def _to_Xy_pipeline(learner, environment):
415413
return learner.__skrub_to_Xy_pipeline__(environment)
416414

@@ -442,8 +440,11 @@ def _estimator_type(self):
442440
first = find_first_apply(self.data_op)
443441
if first is None:
444442
return "transformer"
443+
estimator = get_default(first._skrub_impl.estimator)
444+
if isinstance(estimator, DataOp):
445+
return "transformer"
445446
try:
446-
return get_default(first._skrub_impl.estimator)._estimator_type
447+
return estimator._estimator_type
447448
except AttributeError:
448449
return "transformer"
449450

@@ -454,7 +455,13 @@ def __sklearn_tags__(self):
454455
first = find_first_apply(self.data_op)
455456
if first is None:
456457
return _default_sklearn_tags()
457-
return get_default(first._skrub_impl.estimator).__sklearn_tags__()
458+
estimator = get_default(first._skrub_impl.estimator)
459+
if isinstance(estimator, DataOp):
460+
return _default_sklearn_tags()
461+
try:
462+
return estimator.__sklearn_tags__()
463+
except AttributeError:
464+
return _default_sklearn_tags()
458465

459466
@property
460467
def classes_(self):

skrub/_data_ops/tests/test_data_ops.py

Lines changed: 96 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from sklearn.base import BaseEstimator
99
from sklearn.datasets import make_classification, make_regression
1010
from sklearn.dummy import DummyRegressor
11-
from sklearn.linear_model import LogisticRegression
11+
from sklearn.linear_model import LogisticRegression, Ridge
1212
from sklearn.model_selection import train_test_split
1313

1414
import skrub
@@ -625,12 +625,6 @@ def test_concat_non_str_colname():
625625
)
626626

627627

628-
def test_class_skb():
629-
from skrub._data_ops._skrub_namespace import SkrubNamespace
630-
631-
assert skrub.DataOp.skb is SkrubNamespace
632-
633-
634628
def test_get_vars():
635629
a = skrub.var("a")
636630
b = skrub.var("b")
@@ -639,3 +633,98 @@ def test_get_vars():
639633
assert list(d.skb.get_vars().keys()) == ["a", "b"]
640634
assert d.skb.get_vars()["a"] is a
641635
assert list(d.skb.get_vars(all_named_ops=True).keys()) == ["a", "b", "c"]
636+
637+
638+
@pytest.mark.parametrize("needs_data", [False, True])
639+
@pytest.mark.parametrize("has_preview", [False, True])
640+
@pytest.mark.parametrize("regression", [False, True])
641+
@pytest.mark.parametrize("with_scoring", [False, True])
642+
def test_estimator_is_a_data_op(needs_data, has_preview, regression, with_scoring):
643+
# Check that the data_op, learner and search estimators behave well when
644+
# the estimator passed to apply is a data op
645+
if regression:
646+
X_a, y_a = make_regression(random_state=0)
647+
else:
648+
X_a, y_a = make_classification(random_state=0)
649+
X_df = pd.DataFrame(X_a).rename(columns=str)
650+
if has_preview:
651+
X, y = skrub.X(X_df), skrub.y(y_a)
652+
else:
653+
X, y = skrub.X(), skrub.y()
654+
if needs_data:
655+
# In this case the estimator's automated preview cannot be computed
656+
# because it needs a value from the environment, the value is not known
657+
# until we fit the learner.
658+
659+
def get_vectorizer(X):
660+
return skrub.TableVectorizer()
661+
662+
vectorizer = X.skb.apply_func(get_vectorizer)
663+
664+
def get_predictor(X):
665+
return Ridge() if regression else LogisticRegression()
666+
667+
predictor = X.skb.apply_func(get_predictor)
668+
else:
669+
# In this case the estimator can be evaluated in the automated preview
670+
# when the data op is created.
671+
vectorizer = skrub.as_data_op(skrub.TableVectorizer())
672+
predictor = skrub.as_data_op(Ridge() if regression else LogisticRegression())
673+
pred = X.skb.apply(vectorizer).skb.apply(predictor, y=y)
674+
# no information about the estimator: we expose all methods and default to
675+
# 'transformer' estimator type.
676+
learner = pred.skb.make_learner()
677+
assert learner.__skrub_to_Xy_pipeline__({})._estimator_type == "transformer"
678+
assert hasattr(learner, "predict")
679+
assert hasattr(pred.skb.make_randomized_search(), "predict")
680+
if has_preview:
681+
assert pred.skb.preview().shape == y_a.shape
682+
env = {"X": X_df, "y": y_a}
683+
assert pred.skb.eval(env).shape == y_a.shape
684+
search = pred.skb.make_grid_search(cv=2).fit(env)
685+
min_score = 0.3 if regression else 0.7
686+
assert search.best_score_ > min_score
687+
if with_scoring:
688+
scoring = "r2" if regression else "accuracy"
689+
else:
690+
scoring = None
691+
res = skrub.cross_validate(
692+
pred.skb.make_grid_search(cv=2, scoring=scoring),
693+
environment=env,
694+
cv=2,
695+
scoring=scoring,
696+
)
697+
assert res["test_score"].mean() > min_score
698+
699+
700+
def test_apply_no_sklearn_tags():
701+
# applying an estimator that does not define __sklearn_tags__
702+
class Twice:
703+
def fit(self, X, y=None):
704+
return self
705+
706+
def fit_transform(self, X, y=None):
707+
return X * 2
708+
709+
def transform(self, X):
710+
return X * 2
711+
712+
def get_params(self, deep=True):
713+
return {}
714+
715+
def set_params(self):
716+
return self
717+
718+
learner = skrub.var("a").skb.apply(Twice()).skb.make_learner()
719+
assert learner.fit_transform({"a": 1}) == 2
720+
xy_learner = learner.__skrub_to_Xy_pipeline__({})
721+
assert xy_learner._estimator_type == "transformer"
722+
if hasattr(xy_learner, "__sklearn_tags__"):
723+
# Old scikit-learn versiond don't have __sklearn_tags__
724+
assert xy_learner.__sklearn_tags__().estimator_type is None
725+
726+
727+
def test_class_skb():
728+
from skrub._data_ops._skrub_namespace import SkrubNamespace
729+
730+
assert skrub.DataOp.skb is SkrubNamespace

0 commit comments

Comments
 (0)