Skip to content

Commit b0a3c29

Browse files
add .skb.iter_cv_splits() (#1653)
Co-authored-by: Riccardo Cappuzzo <7548232+rcap107@users.noreply.github.com>
1 parent 539be06 commit b0a3c29

8 files changed

Lines changed: 182 additions & 5 deletions

File tree

CHANGES.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ New features
1313
------------
1414
- TableReport now displays the mean statistic for boolean columns.
1515
:pr:`1647` by :user:`Abdelhakim Benechehab <abenechehab>`.
16+
- :meth:`DataOp.skb.iter_cv_splits` iterates over the training and testing
17+
environments produced by a CV splitter -- similar to
18+
:meth:`DataOp.skb.train_test_split` but for multiple cross-validation splits.
19+
:pr:`1653` by :user:`Jérôme Dockès <jeromedockes>`.
1620

1721
Changes
1822
-------

doc/api_reference.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@
237237
"DataOp.skb.make_grid_search",
238238
"DataOp.skb.make_randomized_search",
239239
"DataOp.skb.if_else",
240+
"DataOp.skb.iter_cv_splits",
240241
"DataOp.skb.iter_learners_grid",
241242
"DataOp.skb.iter_learners_randomized",
242243
"DataOp.skb.mark_as_X",

skrub/_data_ops/_estimator.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
from sklearn import model_selection
55
from sklearn.base import BaseEstimator, TransformerMixin, clone
66
from sklearn.exceptions import NotFittedError
7+
from sklearn.model_selection import KFold, check_cv
78
from sklearn.utils.validation import check_is_fitted
89

10+
from .. import _dataframe as sbd
911
from .. import _join_utils
1012
from ._choosing import BaseNumericChoice, get_default
1113
from ._data_ops import Apply, check_subsampled_X_y_shape
@@ -25,7 +27,7 @@
2527
from ._inspection import describe_params
2628
from ._parallel_coord import DEFAULT_COLORSCALE, plot_parallel_coord
2729
from ._subsampling import env_with_subsampling
28-
from ._utils import X_NAME, Y_NAME, _CloudPickle, attribute_error
30+
from ._utils import KFOLD_5, X_NAME, Y_NAME, _CloudPickle, attribute_error
2931

3032
_FITTING_METHODS = ["fit", "fit_transform"]
3133
_SKLEARN_SEARCH_FITTED_ATTRIBUTES_TO_COPY = [
@@ -675,6 +677,39 @@ def train_test_split(
675677
return result
676678

677679

680+
def iter_cv_splits(data_op, environment, *, keep_subsampling=False, cv=KFOLD_5):
681+
"""Yield splits of an environment into training an testing environments.
682+
683+
This functionality is exposed to users through the
684+
``DataOp.skb.iter_cv_splits()`` method. See the corresponding docstring for
685+
details and examples.
686+
"""
687+
if cv is KFOLD_5:
688+
cv = KFold(5)
689+
cv = check_cv(cv)
690+
environment = env_with_subsampling(data_op, environment, keep_subsampling)
691+
X, y = _compute_Xy(data_op, environment)
692+
for train_idx, test_idx in cv.split(X, y):
693+
X_train, X_test = sbd.select_rows(X, train_idx), sbd.select_rows(X, test_idx)
694+
train_env = {**environment, X_NAME: X_train}
695+
test_env = {**environment, X_NAME: X_test}
696+
split_info = {
697+
"train": train_env,
698+
"test": test_env,
699+
"X_train": X_train,
700+
"X_test": X_test,
701+
}
702+
if y is not None:
703+
y_train, y_test = sbd.select_rows(y, train_idx), sbd.select_rows(
704+
y, test_idx
705+
)
706+
train_env[Y_NAME] = y_train
707+
test_env[Y_NAME] = y_test
708+
split_info["y_train"] = y_train
709+
split_info["y_test"] = y_test
710+
yield split_info
711+
712+
678713
class ParamSearch(_CloudPickleDataOp, BaseEstimator):
679714
"""Learner that evaluates a skrub DataOp with hyperparameter tuning.
680715

skrub/_data_ops/_skrub_namespace.py

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,13 @@
1919
check_name,
2020
deferred,
2121
)
22-
from ._estimator import ParamSearch, SkrubLearner, cross_validate, train_test_split
22+
from ._estimator import (
23+
ParamSearch,
24+
SkrubLearner,
25+
cross_validate,
26+
iter_cv_splits,
27+
train_test_split,
28+
)
2329
from ._evaluation import (
2430
choices,
2531
clone,
@@ -33,7 +39,7 @@
3339
full_report,
3440
)
3541
from ._subsampling import SubsamplePreviews, env_with_subsampling
36-
from ._utils import NULL, attribute_error
42+
from ._utils import KFOLD_5, NULL, attribute_error
3743

3844

3945
def _var_values_provided(data_op, environment):
@@ -1528,9 +1534,9 @@ def train_test_split(
15281534
15291535
- train: a dictionary containing the training environment
15301536
- test: a dictionary containing the test environment
1531-
- X_train: the value of the variable marked with ``skb.mark_as_x()`` in
1537+
- X_train: the value of the variable marked with ``skb.mark_as_X()`` in
15321538
the train environment
1533-
- X_test: the value of the variable marked with ``skb.mark_as_x()`` in
1539+
- X_test: the value of the variable marked with ``skb.mark_as_X()`` in
15341540
the test environment
15351541
- y_train: the value of the variable marked with ``skb.mark_as_y()`` in
15361542
the train environment, if there is one (may not be the case for
@@ -1584,6 +1590,71 @@ def train_test_split(
15841590
**split_func_kwargs,
15851591
)
15861592

1593+
def iter_cv_splits(self, environment=None, *, keep_subsampling=False, cv=KFOLD_5):
1594+
"""Yield splits of an environment into training and testing environments.
1595+
1596+
Parameters
1597+
----------
1598+
environment : dict, optional
1599+
The environment (dict mapping variable names to values) containing the
1600+
full data. If ``None`` (the default), the data is retrieved from the
1601+
DataOp.
1602+
1603+
keep_subsampling : bool, default=False
1604+
If True, and if subsampling has been configured (see
1605+
:meth:`DataOp.skb.subsample`), use a subsample of the data. By
1606+
default subsampling is not applied and all the data is used.
1607+
1608+
cv : int, cross-validation generator or iterable, default=KFold(5)
1609+
The default is 5-fold without shuffling. Can be a cross-validation
1610+
splitter, an iterable yielding pairs of (train, test) indices, or an
1611+
int to specify the number of folds for KFold splitting.
1612+
1613+
Yields
1614+
------
1615+
dict
1616+
For each split, a dict is produced, containing the following keys:
1617+
1618+
- train: a dictionary containing the training environment
1619+
- test: a dictionary containing the test environment
1620+
- X_train: the value of the variable marked with ``skb.mark_as_X()`` in
1621+
the train environment
1622+
- X_test: the value of the variable marked with ``skb.mark_as_X()`` in
1623+
the test environment
1624+
- y_train: the value of the variable marked with ``skb.mark_as_y()`` in
1625+
the train environment, if there is one (may not be the case for
1626+
unsupervised learning).
1627+
- y_test: the value of the variable marked with ``skb.mark_as_y()`` in
1628+
the test environment, if there is one (may not be the case for
1629+
unsupervised learning).
1630+
1631+
Examples
1632+
--------
1633+
>>> import skrub
1634+
>>> from sklearn.dummy import DummyClassifier
1635+
>>> from sklearn.metrics import accuracy_score
1636+
1637+
>>> orders = skrub.var("orders")
1638+
>>> X = orders.skb.drop("delayed").skb.mark_as_X()
1639+
>>> y = orders["delayed"].skb.mark_as_y()
1640+
>>> delayed = X.skb.apply(skrub.TableVectorizer()).skb.apply(
1641+
... DummyClassifier(), y=y
1642+
... )
1643+
>>> df = skrub.datasets.toy_orders().orders
1644+
>>> accuracies = []
1645+
>>> for split in delayed.skb.iter_cv_splits({"orders": df}, cv=3):
1646+
... learner = delayed.skb.make_learner().fit(split["train"])
1647+
... prediction = learner.predict(split["test"])
1648+
... accuracies.append(accuracy_score(split["y_test"], prediction))
1649+
>>> accuracies
1650+
[1.0, 0.0, 1.0]
1651+
"""
1652+
if environment is None:
1653+
environment = self.get_data()
1654+
yield from iter_cv_splits(
1655+
self._data_op, environment, keep_subsampling=keep_subsampling, cv=cv
1656+
)
1657+
15871658
def make_grid_search(self, *, fitted=False, keep_subsampling=False, **kwargs):
15881659
"""Find the best parameters with grid search.
15891660

skrub/_data_ops/_utils.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
class Sentinels(enum.Enum):
1313
NULL = "NULL"
1414
OPTIONAL_VALUE = "value"
15+
KFOLD_5 = "KFold(5)"
1516

1617
def __repr__(self):
1718
return self.value
@@ -22,6 +23,7 @@ def __str__(self):
2223

2324
NULL = Sentinels.NULL
2425
OPTIONAL_VALUE = Sentinels.OPTIONAL_VALUE
26+
KFOLD_5 = Sentinels.KFOLD_5
2527

2628

2729
def simple_repr(data_op):

skrub/_data_ops/tests/test_estimators.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,31 @@ def test_train_test_split(with_y):
422422
assert e.skb.eval() == [7, 6, 5, 4, 3, 2, 1, 0]
423423

424424

425+
def test_iter_cv_splits():
426+
X = skrub.X(np.arange(5) * 10)
427+
splits = X.skb.iter_cv_splits()
428+
s = next(splits)
429+
assert list(s["X_train"]) == list(s["train"]["_skrub_X"]) == [10, 20, 30, 40]
430+
assert list(s["X_test"]) == list(s["test"]["_skrub_X"]) == [0]
431+
s = next(splits)
432+
assert list(s["X_train"]) == list(s["train"]["_skrub_X"]) == [0, 20, 30, 40]
433+
assert list(s["X_test"]) == list(s["test"]["_skrub_X"]) == [10]
434+
435+
X = skrub.X(np.arange(4) * 10)
436+
y = skrub.y(np.arange(4) * -10)
437+
splits = skrub.as_data_op((X, y)).skb.iter_cv_splits(cv=4)
438+
s = next(splits)
439+
assert list(s["X_train"]) == list(s["train"]["_skrub_X"]) == [10, 20, 30]
440+
assert list(s["X_test"]) == list(s["test"]["_skrub_X"]) == [0]
441+
assert list(s["y_train"]) == list(s["train"]["_skrub_y"]) == [-10, -20, -30]
442+
assert list(s["y_test"]) == list(s["test"]["_skrub_y"]) == [0]
443+
s = next(splits)
444+
assert list(s["X_train"]) == list(s["train"]["_skrub_X"]) == [0, 20, 30]
445+
assert list(s["X_test"]) == list(s["test"]["_skrub_X"]) == [10]
446+
assert list(s["y_train"]) == list(s["train"]["_skrub_y"]) == [0, -20, -30]
447+
assert list(s["y_test"]) == list(s["test"]["_skrub_y"]) == [-10]
448+
449+
425450
def test_train_test_split_splitter_renaming():
426451
# TODO remove when `splitter` is removed in 0.7.0
427452
X = skrub.X(list(range(10)))

skrub/_dataframe/_common.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@
103103
"sample",
104104
"head",
105105
"slice",
106+
"select_rows",
106107
"replace",
107108
"with_columns",
108109
"abs",
@@ -1355,6 +1356,26 @@ def _slice_polars(obj, *start_stop):
13551356
return obj.slice(start, stop - start)
13561357

13571358

1359+
@dispatch
1360+
def select_rows(obj, idx):
1361+
return np.asarray(obj)[list(idx)]
1362+
1363+
1364+
@select_rows.specialize("pandas")
1365+
def _select_rows_pandas(obj, idx):
1366+
return obj.iloc[list(idx)]
1367+
1368+
1369+
@select_rows.specialize("polars")
1370+
def _select_rows_polars(obj, idx):
1371+
idx = list(idx)
1372+
if not idx:
1373+
# polars changed from interpreting indexing with an empty list as list
1374+
# of columns to list of row indices at some point.
1375+
return obj.head(0)
1376+
return obj[idx]
1377+
1378+
13581379
@dispatch
13591380
def replace(col, old, new):
13601381
raise_dispatch_unregistered_type(col, kind="Series")

skrub/_dataframe/tests/test_common.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ def test_not_implemented():
3535
"copy_index",
3636
"index",
3737
"with_columns",
38+
"select_rows",
3839
}
3940
for func_name in sorted(set(ns.__all__) - has_default_impl):
4041
func = getattr(ns, func_name)
@@ -786,6 +787,23 @@ def test_slice(df_module, obj, s):
786787
assert_array_equal(out, expected)
787788

788789

790+
@pytest.mark.parametrize("obj", ["column", "dataframe"])
791+
@pytest.mark.parametrize("idx", [[], [1], [2, 1]])
792+
def test_select_rows(df_module, obj, idx):
793+
out = ns.select_rows(getattr(df_module, f"example_{obj}"), idx)
794+
if obj == "dataframe":
795+
out = ns.col(out, "float-col")
796+
out = ns.to_numpy(out)
797+
expected = ns.to_numpy(df_module.example_column)[idx]
798+
assert_array_equal(out, expected)
799+
800+
801+
def test_select_rows_array():
802+
a = np.arange(6).reshape((3, 2))
803+
assert_array_equal(ns.select_rows(a, (2, 1)), a[[2, 1], :])
804+
assert_array_equal(ns.select_rows(a[0], (1, 0)), a[0, [1, 0]])
805+
806+
789807
def test_is_null(df_module):
790808
s = ns.pandas_convert_dtypes(df_module.make_column("", [0, None, 2, None, 4]))
791809
expected = df_module.make_column("", [False, True, False, True, False])

0 commit comments

Comments
 (0)