Repository: skrub-data/skrub
Pull request #1478 (2025-06-27 13:07:21)

Expose `ApplyToCols` and `ApplyToFrame`
Fixes https://github.com/skrub-data/skrub/issues/1429

rcap107: This will be a fun one to review
jeromedockes: > ahahah indeed. I apologised in advance

:sweat_smile: thanks for plowing through
GaelVaroquaux: Is this ready to review / merge?
rcap107: I'll review this tomorrow, I haven't had the time yet 

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 58fa5be..6d1a267 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -44,6 +44,10 @@ New features
   and :func:`unpatch_display` are deprecated and will be removed in the next release
   of skrub. :pr:`1427` by :user:`Vincent Maladiere <Vincent-Maladiere>`.
 
+- :class:`ApplyToCols` and :class:`ApplyToFrame` are now available to apply transformers
+  on a set of columns independently and jointly respectively.
+  :pr:`1478` by :user:`Vincent Maladiere<Vincent-Maladiere>`.
+
 Changes
 -------
 .. warning::
diff --git a/doc/api_reference.py b/doc/api_reference.py
index dc7e827..cf54f0d 100644
--- a/doc/api_reference.py
+++ b/doc/api_reference.py
@@ -66,6 +66,8 @@ API_REFERENCE = {
                     "Cleaner",
                     "SelectCols",
                     "DropCols",
+                    "ApplyToCols",
+                    "ApplyToFrame",
                     "DropUninformative",
                 ],
             }
@@ -267,6 +269,21 @@ API_REFERENCE = {
             },
         ],
     },
+    "config": {
+        "title": "Configuration",
+        "short_summary": None,
+        "description": None,
+        "sections": [
+            {
+                "description": None,
+                "autosummary": [
+                    "get_config",
+                    "set_config",
+                    "config_context",
+                ],
+            }
+        ],
+    },
     "datasets": {
         "title": "Datasets",
         "short_summary": None,
diff --git a/examples/02_text_with_string_encoders.py b/examples/02_text_with_string_encoders.py
index b1ab90f..41c9ceb 100644
--- a/examples/02_text_with_string_encoders.py
+++ b/examples/02_text_with_string_encoders.py
@@ -237,7 +237,7 @@ plot_box_results(results)
 # case, 30.
 from skrub import StringEncoder
 
-string_encoder = StringEncoder(ngram_range=(3, 4), analyzer="char_wb")
+string_encoder = StringEncoder(ngram_range=(3, 4), analyzer="char_wb", random_state=0)
 
 string_encoder_pipe = make_pipeline(
     TableVectorizer(high_cardinality=string_encoder),
@@ -302,12 +302,13 @@ def plot_performance_tradeoff(results):
         ax.set_title("Prediction performance / training time trade-off")
 
     ax.annotate(
-        "",
-        xy=(1.5, 0.98),
-        xytext=(8.5, 0.90),
-        arrowprops=dict(arrowstyle="->", mutation_scale=15),
+        "Best time / \nperformance trade-off",
+        xy=(0.05, 0.95),
+        xycoords="axes fraction",
+        xytext=(0.2, 0.8),
+        textcoords="axes fraction",
+        arrowprops=dict(arrowstyle="->", lw=1.5, mutation_scale=15),
     )
-    ax.text(5.8, 0.86, "Best time / \nperformance trade-off")
     ax.legend(bbox_to_anchor=(1.02, 0.3))
     plt.show()
 
diff --git a/examples/10_apply_on_cols.py b/examples/10_apply_on_cols.py
new file mode 100644
index 0000000..716685b
--- /dev/null
+++ b/examples/10_apply_on_cols.py
@@ -0,0 +1,138 @@
+"""
+Hands-On with Column Selection and Transformers
+===============================================
+
+In previous examples, we saw how skrub provides powerful abstractions like
+:class:`~skrub.TableVectorizer` and :func:`~skrub.tabular_learner` to create pipelines.
+
+In this new example, we show how to create more flexible pipelines by selecting
+and transforming dataframe columns using arbitrary logic.
+"""
+
+# %%
+# We begin with loading a dataset with heterogeneous datatypes, and replacing Pandas's
+# display with the TableReport display via :func:`skrub.set_config`.
+import skrub
+from skrub.datasets import fetch_employee_salaries
+
+skrub.set_config(use_tablereport=True)
+data = fetch_employee_salaries()
+X, y = data.X, data.y
+X
+
+# %%
+# Our goal is now to apply a :class:`~skrub.StringEncoder` to two columns of our
+# choosing: ``division`` and ``employee_position_title``.
+#
+# We can achieve this using :class:`~skrub.ApplyToCols`, whose job is to apply a
+# transformer to multiple columns independently, and let unmatched columns through
+# without changes.
+# This can be seen as a handy drop-in replacement of the
+# :class:`~sklearn.compose.ColumnTransformer`.
+#
+# Since we selected two columns and set the number of components to ``30`` each,
+# :class:`~skrub.ApplyToCols` will create ``2*30`` embedding columns in the dataframe
+# ``Xt``, which we prefix with ``lsa_``.
+from skrub import ApplyToCols, StringEncoder
+
+apply_string_encoder = ApplyToCols(
+    StringEncoder(n_components=30),
+    cols=["division", "employee_position_title"],
+    rename_columns="lsa_{}",
+)
+Xt = apply_string_encoder.fit_transform(X)
+Xt
+
+# %%
+# In addition to the :class:`~skrub.ApplyToCols` class, the
+# :class:`~skrub.ApplyToFrame` class is useful for transformers that work on multiple
+# columns at once, such as the :class:`~sklearn.decomposition.PCA` which reduces the
+# number of components.
+#
+# To select columns without hardcoding their names, we introduce
+# :ref:`selectors<selectors>`, which allow for flexible matching pattern and composable
+# logic.
+#
+# The regex selector below will match all columns prefixed with ``"lsa"``, and pass them
+# to :class:`~skrub.ApplyToFrame` which will assemble these columns into a dataframe and
+# finally pass it to the PCA.
+from sklearn.decomposition import PCA
+
+from skrub import ApplyToFrame
+from skrub import selectors as s
+
+apply_pca = ApplyToFrame(PCA(n_components=8), cols=s.regex("lsa"))
+Xt = apply_pca.fit_transform(Xt)
+Xt
+
+# %%
+# These two selectors are scikit-learn transformers and can be chained together within
+# a :class:`~sklearn.pipeline.Pipeline`.
+from sklearn.pipeline import make_pipeline
+
+make_pipeline(
+    apply_string_encoder,
+    apply_pca,
+).fit_transform(X)
+
+# %%
+# Note that selectors also come in handy in a pipeline to select or drop columns, using
+# :class:`~skrub.SelectCols` and :class:`~skrub.DropCols`!
+from sklearn.preprocessing import StandardScaler
+
+from skrub import SelectCols
+
+# Select only numerical columns
+pipeline = make_pipeline(
+    SelectCols(cols=s.numeric()),
+    StandardScaler(),
+).set_output(transform="pandas")
+pipeline.fit_transform(Xt)
+
+# %%
+# Let's run through one more example to showcase the expressiveness of the selectors.
+# Suppose we want to apply an :class:`~sklearn.preprocessing.OrdinalEncoder` on
+# categorical columns with low cardinality (e.g., fewer than ``40`` unique values).
+#
+# We define a column filter using skrub selectors with a lambda function. Note that
+# the same effect can be obtained directly by using
+# :func:`~srkub.selectors.cardinality_below`.
+from sklearn.preprocessing import OrdinalEncoder
+
+low_cardinality = s.filter(lambda col: col.nunique() < 40)
+ApplyToCols(OrdinalEncoder(), cols=s.string() & low_cardinality).fit_transform(X)
+
+# %%
+# Notice how we composed the selector with :func:`~skrub.selectors.string()`
+# using a logical operator. This resulting selector matches string
+# columns with cardinality below ``40``.
+#
+# We can also define the opposite selector ``high_cardinality`` using the negation
+# operator ``~`` and apply a :class:`skrub.StringEncoder` to vectorize those
+# columns.
+from sklearn.ensemble import HistGradientBoostingRegressor
+
+high_cardinality = ~low_cardinality
+pipeline = make_pipeline(
+    ApplyToCols(
+        OrdinalEncoder(),
+        cols=s.string() & low_cardinality,
+    ),
+    ApplyToCols(
+        StringEncoder(),
+        cols=s.string() & high_cardinality,
+    ),
+    HistGradientBoostingRegressor(),
+).fit(X, y)
+pipeline
+
+# %%
+# Interestingly, the pipeline above is similar to the datatype dispatching performed by
+# :class:`~skrub.TableVectorizer`, also used in :func:`~skrub.tabular_learner`.
+#
+# Click on the dropdown arrows next to the datatype to see the columns are mapped to
+# the different transformers in :class:`~skrub.TableVectorizer`.
+from skrub import tabular_learner
+
+tabular_learner("regressor").fit(X, y)
+# %%
diff --git a/skrub/__init__.py b/skrub/__init__.py
index 9bbc0b5..0588d2c 100644
--- a/skrub/__init__.py
+++ b/skrub/__init__.py
@@ -6,6 +6,8 @@ from pathlib import Path as _Path
 
 from . import selectors
 from ._agg_joiner import AggJoiner, AggTarget
+from ._apply_to_cols import ApplyToCols
+from ._apply_to_frame import ApplyToFrame
 from ._column_associations import column_associations
 from ._config import config_context, get_config, set_config
 from ._datetime_encoder import DatetimeEncoder
@@ -98,4 +100,6 @@ __all__ = [
     "get_config",
     "set_config",
     "config_context",
+    "ApplyToCols",
+    "ApplyToFrame",
 ]
diff --git a/skrub/_on_each_column.py b/skrub/_apply_to_cols.py
similarity index 90%
rename from skrub/_on_each_column.py
rename to skrub/_apply_to_cols.py
index f1c0acf..ec4cbd9 100644
--- a/skrub/_on_each_column.py
+++ b/skrub/_apply_to_cols.py
@@ -11,7 +11,7 @@ from . import _dataframe as sbd
 from . import _utils, selectors
 from ._join_utils import pick_column_names
 
-__all__ = ["OnEachColumn", "SingleColumnTransformer", "RejectColumn"]
+__all__ = ["ApplyToCols", "SingleColumnTransformer", "RejectColumn"]
 
 _SINGLE_COL_LINE = (
     "``{class_name}`` is a type of single-column transformer. Unlike most scikit-learn"
@@ -42,7 +42,7 @@ class RejectColumn(ValueError):
     >>> ToDatetime().fit_transform(df['b'])
     Traceback (most recent call last):
         ...
-    skrub._on_each_column.RejectColumn: Column 'b' does not contain strings.
+    skrub._apply_to_cols.RejectColumn: Column 'b' does not contain strings.
     """
 
     pass
@@ -52,11 +52,11 @@ class SingleColumnTransformer(BaseEstimator):
     """Base class for single-column transformers.
 
     Such transformers are applied independently to each column by
-    ``OnEachColumn``; see the docstring of ``OnEachColumn`` for more
+    ``ApplyToCols``; see the docstring of ``ApplyToCols`` for more
     information.
 
     Single-column transformers are not required to inherit from this class in
-    order to work with ``OnEachColumn``, however doing so avoids some
+    order to work with ``ApplyToCols``, however doing so avoids some
     boilerplate:
 
         - The required ``__single_column_transformer__`` attribute is set.
@@ -213,7 +213,7 @@ def _insert_after_first_paragraph(document, text_to_insert):
     return "".join(output_lines)
 
 
-class OnEachColumn(TransformerMixin, BaseEstimator):
+class ApplyToCols(TransformerMixin, BaseEstimator):
     """Map a transformer to columns in a dataframe.
 
     A separate clone of the transformer is applied to each column separately.
@@ -254,7 +254,7 @@ class OnEachColumn(TransformerMixin, BaseEstimator):
     allow_reject : bool, default=False
         Whether the transformer is allowed to reject a column by raising a
         ``RejectColumn`` exception. If ``True``, rejected columns will be
-        passed through unchanged by ``OnEachColumn`` and will not appear in
+        passed through unchanged by ``ApplyToCols`` and will not appear in
         attributes such as ``transformers_``, ``used_inputs_``, etc. If
         ``False``, column rejections are considered as errors and
         ``RejectColumn`` exceptions are propagated.
@@ -309,7 +309,7 @@ class OnEachColumn(TransformerMixin, BaseEstimator):
     Examples
     --------
     >>> import pandas as pd
-    >>> from skrub._on_each_column import OnEachColumn
+    >>> from skrub import ApplyToCols
     >>> from sklearn.preprocessing import StandardScaler
     >>> df = pd.DataFrame(dict(A=[-10., 10.], B=[-10., 0.], C=[0., 10.]))
     >>> df
@@ -319,7 +319,7 @@ class OnEachColumn(TransformerMixin, BaseEstimator):
 
     Fit a StandardScaler to each column in df:
 
-    >>> scaler = OnEachColumn(StandardScaler())
+    >>> scaler = ApplyToCols(StandardScaler())
     >>> scaler.fit_transform(df)
          A    B    C
     0 -1.0 -1.0 -1.0
@@ -329,7 +329,7 @@ class OnEachColumn(TransformerMixin, BaseEstimator):
 
     We can restrict the columns on which the transformation is applied:
 
-    >>> scaler = OnEachColumn(StandardScaler(), cols=["A", "B"])
+    >>> scaler = ApplyToCols(StandardScaler(), cols=["A", "B"])
     >>> scaler.fit_transform(df)
          A    B     C
     0 -1.0 -1.0   0.0
@@ -363,13 +363,13 @@ class OnEachColumn(TransformerMixin, BaseEstimator):
     >>> ToDatetime().fit_transform(df["city"])
     Traceback (most recent call last):
         ...
-    skrub._on_each_column.RejectColumn: Could not find a datetime format for column 'city'.
+    skrub._apply_to_cols.RejectColumn: Could not find a datetime format for column 'city'.
 
     How these rejections are handled depends on the ``allow_reject`` parameter.
     By default, no special handling is performed and rejections are considered
     to be errors:
 
-    >>> to_datetime = OnEachColumn(ToDatetime())
+    >>> to_datetime = ApplyToCols(ToDatetime())
     >>> to_datetime.fit_transform(df)
     Traceback (most recent call last):
         ...
@@ -381,7 +381,7 @@ class OnEachColumn(TransformerMixin, BaseEstimator):
     Therefore it might be sensible to try to parse all string columns but allow
     the transformer to reject those that, upon inspection, do not contain dates.
 
-    >>> to_datetime = OnEachColumn(ToDatetime(), allow_reject=True)
+    >>> to_datetime = ApplyToCols(ToDatetime(), allow_reject=True)
     >>> transformed = to_datetime.fit_transform(df)
     >>> transformed
         birthday    city
@@ -403,7 +403,7 @@ class OnEachColumn(TransformerMixin, BaseEstimator):
     The ``rename_columns`` parameter allows renaming output columns.
 
     >>> df = pd.DataFrame(dict(A=[-10., 10.], B=[0., 100.]))
-    >>> scaler = OnEachColumn(StandardScaler(), rename_columns='{}_scaled')
+    >>> scaler = ApplyToCols(StandardScaler(), rename_columns='{}_scaled')
     >>> scaler.fit_transform(df)
        A_scaled  B_scaled
     0      -1.0      -1.0
@@ -412,19 +412,19 @@ class OnEachColumn(TransformerMixin, BaseEstimator):
     The renaming is only applied to columns selected by ``cols`` (and not
     rejected by the transformer when ``allow_reject`` is ``True``).
 
-    >>> scaler = OnEachColumn(StandardScaler(), cols=['A'], rename_columns='{}_scaled')
+    >>> scaler = ApplyToCols(StandardScaler(), cols=['A'], rename_columns='{}_scaled')
     >>> scaler.fit_transform(df)
        A_scaled      B
     0      -1.0    0.0
     1       1.0  100.0
 
     ``rename_columns`` can be particularly useful when ``keep_original`` is
-    ``True``. When a column is transformed, we can tell ``OnEachColumn`` to
+    ``True``. When a column is transformed, we can tell ``ApplyToCols`` to
     retain the original, untransformed column in the output. If the transformer
     produces a column with the same name, the transformation result is renamed
     to avoid a name clash.
 
-    >>> scaler = OnEachColumn(StandardScaler(), keep_original=True)
+    >>> scaler = ApplyToCols(StandardScaler(), keep_original=True)
     >>> scaler.fit_transform(df)                                    # doctest: +SKIP
           A  A__skrub_89725c56__      B  B__skrub_81cc7d00__
     0 -10.0                 -1.0    0.0                 -1.0
@@ -432,7 +432,7 @@ class OnEachColumn(TransformerMixin, BaseEstimator):
 
     In this case we may want to set a more sensible name for the transformer's output:
 
-    >>> scaler = OnEachColumn(
+    >>> scaler = ApplyToCols(
     ...     StandardScaler(), keep_original=True, rename_columns="{}_scaled"
     ... )
     >>> scaler.fit_transform(df)
@@ -458,10 +458,40 @@ class OnEachColumn(TransformerMixin, BaseEstimator):
         self.n_jobs = n_jobs
 
     def fit(self, X, y=None):
+        """Fit the transformer on each column independently.
+
+        Parameters
+        ----------
+        X : Pandas or Polars DataFrame
+            The data to transform.
+
+        y : Pandas or Polars Series or DataFrame, default=None
+            The target data.
+
+        Returns
+        -------
+        ApplyToCols
+            The transformer itself.
+        """
         self.fit_transform(X, y)
         return self
 
     def fit_transform(self, X, y=None):
+        """Fit the transformer on each column independently and transform X.
+
+        Parameters
+        ----------
+        X : Pandas or Polars DataFrame
+            The data to transform.
+
+        y : Pandas or Polars Series or DataFrame, default=None
+            The target data.
+
+        Returns
+        -------
+        result : Pandas or Polars DataFrame
+            The transformed data.
+        """
         self._columns = selectors.make_selector(self.cols).expand(X)
         results = []
         all_columns = sbd.column_names(X)
@@ -480,6 +510,18 @@ class OnEachColumn(TransformerMixin, BaseEstimator):
         return self._process_fit_transform_results(results, X)
 
     def transform(self, X):
+        """Transform a dataframe.
+
+        Parameters
+        ----------
+        X : Pandas or Polars DataFrame
+            The column to transform.
+
+        Returns
+        -------
+        result : Pandas or Polars DataFrame
+            The transformed data.
+        """
         check_is_fitted(self, "transformers_")
         parallel = Parallel(n_jobs=self.n_jobs)
         func = delayed(_transform_column)
@@ -541,6 +583,14 @@ class OnEachColumn(TransformerMixin, BaseEstimator):
     # set_output api compatibility
 
     def get_feature_names_out(self):
+        """Get output feature names for transformation.
+
+        Returns
+        -------
+        feature_names_out : ndarray of str objects
+            Transformed feature names.
+        """
+        check_is_fitted(self, "all_outputs_")
         return self.all_outputs_
 
 
diff --git a/skrub/_on_subframe.py b/skrub/_apply_to_frame.py
similarity index 81%
rename from skrub/_on_subframe.py
rename to skrub/_apply_to_frame.py
index 1b810f1..bfe23d1 100644
--- a/skrub/_on_subframe.py
+++ b/skrub/_apply_to_frame.py
@@ -5,14 +5,14 @@ from . import _dataframe as sbd
 from . import _utils, selectors
 from ._join_utils import pick_column_names
 
-__all__ = ["OnSubFrame"]
+__all__ = ["ApplyToFrame"]
 
 
-class OnSubFrame(TransformerMixin, BaseEstimator):
+class ApplyToFrame(TransformerMixin, BaseEstimator):
     """Apply a transformer to part of a dataframe.
 
     A subset of the dataframe is selected and passed to the transformer (as a
-    single input). This is different from ``OnEachColumn`` which fits a
+    single input). This is different from ``ApplyToCols``, which fits a
     separate clone of the transformer to each selected column independently.
 
     .. note::
@@ -78,8 +78,8 @@ class OnSubFrame(TransformerMixin, BaseEstimator):
     2  0.0   0.0  100.0     0.0
     3  0.0   0.0    0.0  1000.0
     >>> from sklearn.decomposition import PCA
-    >>> from skrub._on_subframe import OnSubFrame
-    >>> OnSubFrame(PCA(n_components=2)).fit_transform(df).round(2)
+    >>> from skrub import ApplyToFrame
+    >>> ApplyToFrame(PCA(n_components=2)).fit_transform(df).round(2)
          pca0   pca1
     0 -249.01 -33.18
     1 -249.04 -33.68
@@ -88,7 +88,7 @@ class OnSubFrame(TransformerMixin, BaseEstimator):
 
     We can restrict the transformer to a subset of columns:
 
-    >>> pca = OnSubFrame(PCA(n_components=2), cols=["a", "b"])
+    >>> pca = ApplyToFrame(PCA(n_components=2), cols=["a", "b"])
     >>> pca.fit_transform(df).round(2)
            c       d  pca0  pca1
     0    0.0     0.0 -2.52  0.67
@@ -104,7 +104,7 @@ class OnSubFrame(TransformerMixin, BaseEstimator):
 
     It is possible to rename the output columns:
 
-    >>> pca = OnSubFrame(
+    >>> pca = ApplyToFrame(
     ...     PCA(n_components=2), cols=["a", "b"], rename_columns='my_tag-{}'
     ... )
     >>> pca.fit_transform(df).round(2)
@@ -116,7 +116,7 @@ class OnSubFrame(TransformerMixin, BaseEstimator):
 
     We can also force preserving the original columns in the output:
 
-    >>> pca = OnSubFrame(PCA(n_components=2), cols=["a", "b"], keep_original=True)
+    >>> pca = ApplyToFrame(PCA(n_components=2), cols=["a", "b"], keep_original=True)
     >>> pca.fit_transform(df).round(2)
          a     b      c       d  pca0  pca1
     0  1.0   0.0    0.0     0.0 -2.52  0.67
@@ -138,10 +138,40 @@ class OnSubFrame(TransformerMixin, BaseEstimator):
         self.rename_columns = rename_columns
 
     def fit(self, X, y=None):
+        """Fit the transformer on all columns jointly.
+
+        Parameters
+        ----------
+        X : Pandas or Polars DataFrame
+            The data to transform.
+
+        y : Pandas or Polars Series or DataFrame, default=None
+            The target data.
+
+        Returns
+        -------
+        ApplyToFrame
+            The transformer itself.
+        """
         self.fit_transform(X, y)
         return self
 
     def fit_transform(self, X, y=None):
+        """Fit the transformer on all columns jointly and transform X.
+
+        Parameters
+        ----------
+        X : Pandas or Polars DataFrame
+            The data to transform.
+
+        y : Pandas or Polars Series or DataFrame, default=None
+            The target data.
+
+        Returns
+        -------
+        result : Pandas or Polars DataFrame
+            The transformed data.
+        """
         self.all_inputs_ = sbd.column_names(X)
         self._columns = selectors.make_selector(self.cols).expand(X)
         to_transform = selectors.select(X, self._columns)
@@ -183,6 +213,18 @@ class OnSubFrame(TransformerMixin, BaseEstimator):
         return result
 
     def transform(self, X):
+        """Transform a dataframe.
+
+        Parameters
+        ----------
+        X : Pandas or Polars DataFrame
+            The column to transform.
+
+        Returns
+        -------
+        result : Pandas or Polars DataFrame
+            The transformed data.
+        """
         check_is_fitted(self, "transformer_")
 
         # do the selection even if self._columns is empty to raise if X doesn't
@@ -206,4 +248,12 @@ class OnSubFrame(TransformerMixin, BaseEstimator):
     # set_output api compatibility
 
     def get_feature_names_out(self):
+        """Get output feature names for transformation.
+
+        Returns
+        -------
+        feature_names_out : ndarray of str objects
+            Transformed feature names.
+        """
+        check_is_fitted(self, "all_outputs_")
         return self.all_outputs_
diff --git a/skrub/_clean_categories.py b/skrub/_clean_categories.py
index 49eba79..0723405 100644
--- a/skrub/_clean_categories.py
+++ b/skrub/_clean_categories.py
@@ -1,9 +1,9 @@
 import numpy as np
 
 from . import _dataframe as sbd
+from ._apply_to_cols import RejectColumn, SingleColumnTransformer
 from ._dataframe._common import _raise as _sbd_raise
 from ._dispatch import dispatch
-from ._on_each_column import RejectColumn, SingleColumnTransformer
 
 __all__ = ["CleanCategories"]
 
@@ -151,7 +151,7 @@ class CleanCategories(SingleColumnTransformer):
     >>> cleaner.fit_transform(s)
     Traceback (most recent call last):
         ...
-    skrub._on_each_column.RejectColumn: Column 'c' is not categorical.
+    skrub._apply_to_cols.RejectColumn: Column 'c' is not categorical.
 
     However once a column has been accepted, the output of ``transform`` will
     always have a categorical dtype:
diff --git a/skrub/_clean_null_strings.py b/skrub/_clean_null_strings.py
index 83be582..adcb48b 100644
--- a/skrub/_clean_null_strings.py
+++ b/skrub/_clean_null_strings.py
@@ -1,7 +1,7 @@
 from . import _dataframe as sbd
+from ._apply_to_cols import RejectColumn, SingleColumnTransformer
 from ._dataframe._common import _raise as _sbd_raise
 from ._dispatch import dispatch
-from ._on_each_column import RejectColumn, SingleColumnTransformer
 
 __all__ = ["CleanNullStrings"]
 
@@ -163,7 +163,7 @@ class CleanNullStrings(SingleColumnTransformer):
     >>> ToFloat32().fit_transform(s)
     Traceback (most recent call last):
         ...
-    skrub._on_each_column.RejectColumn: Could not convert column 's' to numbers.
+    skrub._apply_to_cols.RejectColumn: Could not convert column 's' to numbers.
     >>> ToFloat32().fit_transform(cleaner.fit_transform(s))
     0    1.1
     1    2.2
@@ -178,7 +178,7 @@ class CleanNullStrings(SingleColumnTransformer):
     >>> cleaner.fit_transform(s)
     Traceback (most recent call last):
         ...
-    skrub._on_each_column.RejectColumn: Column 's' does not contain strings.
+    skrub._apply_to_cols.RejectColumn: Column 's' does not contain strings.
 
     In particular, Categorical columns, although they contain strings, do not
     have the ``string`` or ``object`` ``dtype``:
@@ -187,7 +187,7 @@ class CleanNullStrings(SingleColumnTransformer):
     >>> cleaner.fit_transform(s)
     Traceback (most recent call last):
         ...
-    skrub._on_each_column.RejectColumn: Column None does not contain strings.
+    skrub._apply_to_cols.RejectColumn: Column None does not contain strings.
 
     Note however that ``object`` columns are accepted even if they do not
     contain any strings. They will not be modified but they will still be
@@ -230,7 +230,7 @@ class CleanNullStrings(SingleColumnTransformer):
     >>> cleaner.fit_transform(s)
     Traceback (most recent call last):
         ...
-    skrub._on_each_column.RejectColumn: Column 's' does not contain strings.
+    skrub._apply_to_cols.RejectColumn: Column 's' does not contain strings.
     """
 
     def fit_transform(self, column, y=None):
diff --git a/skrub/_datetime_encoder.py b/skrub/_datetime_encoder.py
index d71f29f..d005b1e 100644
--- a/skrub/_datetime_encoder.py
+++ b/skrub/_datetime_encoder.py
@@ -6,8 +6,8 @@ from sklearn.preprocessing import SplineTransformer
 from sklearn.utils.validation import check_is_fitted
 
 from . import _dataframe as sbd
+from ._apply_to_cols import RejectColumn, SingleColumnTransformer
 from ._dispatch import dispatch
-from ._on_each_column import RejectColumn, SingleColumnTransformer
 from ._sklearn_compat import TransformerTags
 
 __all__ = ["DatetimeEncoder"]
@@ -257,7 +257,7 @@ class DatetimeEncoder(SingleColumnTransformer):
     >>> DatetimeEncoder().fit_transform(s)
     Traceback (most recent call last):
         ...
-    skrub._on_each_column.RejectColumn: Column 'birthday' does not have Date or Datetime dtype.
+    skrub._apply_to_cols.RejectColumn: Column 'birthday' does not have Date or Datetime dtype.
 
     :class:`ToDatetime`: can be used for converting strings to datetimes.
 
diff --git a/skrub/_drop_if_too_many_nulls.py b/skrub/_drop_if_too_many_nulls.py
index 4d3c432..8c08a2a 100644
--- a/skrub/_drop_if_too_many_nulls.py
+++ b/skrub/_drop_if_too_many_nulls.py
@@ -4,7 +4,7 @@ import warnings
 from sklearn.utils.validation import check_is_fitted
 
 from . import _dataframe as sbd
-from ._on_each_column import SingleColumnTransformer
+from ._apply_to_cols import SingleColumnTransformer
 
 __all__ = ["DropIfTooManyNulls"]
 
diff --git a/skrub/_drop_uninformative.py b/skrub/_drop_uninformative.py
index 4921557..7d7e1c3 100644
--- a/skrub/_drop_uninformative.py
+++ b/skrub/_drop_uninformative.py
@@ -3,7 +3,7 @@ import numbers
 from sklearn.utils.validation import check_is_fitted
 
 from . import _dataframe as sbd
-from ._on_each_column import SingleColumnTransformer
+from ._apply_to_cols import SingleColumnTransformer
 
 __all__ = ["DropUninformative"]
 
diff --git a/skrub/_expressions/_estimator.py b/skrub/_expressions/_estimator.py
index e2b4785..97cb4cc 100644
--- a/skrub/_expressions/_estimator.py
+++ b/skrub/_expressions/_estimator.py
@@ -192,7 +192,7 @@ class SkrubPipeline(_CloudPickleExpr, BaseEstimator):
         -------
         scikit-learn estimator
             The fitted estimator. Depending on the nature of the estimator it
-            may be wrapped in a ``skrub.OnEachColumn`` or ``skrub.OnSubFrame``,
+            may be wrapped in a ``skrub.ApplyToCols`` or ``skrub.ApplyToFrame``,
             see examples below.
 
         See also
@@ -240,7 +240,7 @@ class SkrubPipeline(_CloudPickleExpr, BaseEstimator):
 
         Case 1: the ``StringEncoder`` is a skrub single-column transformer: it
         transforms a single column. In the pipeline it gets wrapped in a
-        ``skrub.OnEachColumn`` which independently fits a separate instance of the
+        ``skrub.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
@@ -252,25 +252,25 @@ class SkrubPipeline(_CloudPickleExpr, BaseEstimator):
         >>> 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 ``OnEachColumn``) happens when the estimator is a skrub
+        This case (wrapping in ``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
         ``.skb.apply(allow_reject=True)``.
 
         Case 2: the ``PCA`` is a regular scikit-learn transformer. In the pipeline it
-        gets wrapped in a ``skrub.OnSubFrame`` which applies it to the subset of columns
+        gets wrapped in a ``skrub.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_``.
 
         >>> pca = pipeline.find_fitted_estimator('pca')
         >>> pca
-        OnSubFrame(cols=glob('date_*'), transformer=PCA(n_components=2))
+        ApplyToFrame(cols=glob('date_*'), transformer=PCA(n_components=2))
         >>> pca.transformer_
         PCA(n_components=2)
         >>> pca.transformer_.mean_
         array([2020.,    4.,    4.], dtype=float32)
 
-        This case (wrapping in ``OnSubFrame``) happens when the estimator is a
+        This case (wrapping in ``ApplyToFrame``) happens when the estimator is a
         scikit-learn transformer but not a single-column transformer.
 
         The ``DummyRegressor`` is a scikit-learn predictor. In the pipeline it gets
@@ -368,7 +368,7 @@ class SkrubPipeline(_CloudPickleExpr, BaseEstimator):
         step:
 
         >>> pipeline.find_fitted_estimator("vectorizer")
-        OnSubFrame(transformer=TableVectorizer(datetime=DatetimeEncoder(add_total_seconds=False)))
+        ApplyToFrame(transformer=TableVectorizer(datetime=DatetimeEncoder(add_total_seconds=False)))
         """  # noqa: E501
         node = find_node_by_name(self.expr, name)
         if node is None:
@@ -538,8 +538,8 @@ def cross_validate(pipeline, environment, *, keep_subsampling=False, **kwargs):
     """Cross-validate a pipeline built from an expression.
 
     This runs cross-validation from a pipeline that was built from a skrub
-    expression with ``.skb.get_pipeline()``, ``.skb.get_grid_search()`` or
-    ``.skb.get_randomized_search()``.
+    expression with :func:`Expr.skb.get_pipeline`, :func:`Expr.skb.get_grid_search` or
+    :func:`Expr.skb.get_randomized_search`.
 
     It is useful to run nested cross-validation of a grid search or randomized
     search.
diff --git a/skrub/_expressions/_expressions.py b/skrub/_expressions/_expressions.py
index 7df5d2e..59e3966 100644
--- a/skrub/_expressions/_expressions.py
+++ b/skrub/_expressions/_expressions.py
@@ -748,7 +748,7 @@ def _check_estimator_type(estimator):
 
 def _wrap_estimator(estimator, cols, how, allow_reject, X):
     """
-    Wrap the estimator passed to .skb.apply in OnEachColumn or OnSubFrame if
+    Wrap the estimator passed to .skb.apply in ApplyToCols or ApplyToFrame if
     needed.
     """
     if estimator in [None, "passthrough"]:
@@ -1271,7 +1271,7 @@ class Apply(ExprImpl):
 
     def __repr__(self):
         estimator = get_chosen_or_default(self.estimator)
-        if estimator.__class__.__name__ in ["OnEachColumn", "OnSubFrame"]:
+        if estimator.__class__.__name__ in ["ApplyToCols", "ApplyToFrame"]:
             estimator = estimator.transformer
         # estimator can be None or 'passthrough'
         if isinstance(estimator, str):
diff --git a/skrub/_expressions/_skrub_namespace.py b/skrub/_expressions/_skrub_namespace.py
index 7e1c6ee..903866c 100644
--- a/skrub/_expressions/_skrub_namespace.py
+++ b/skrub/_expressions/_skrub_namespace.py
@@ -1115,9 +1115,9 @@ class SkrubNamespace:
     def describe_steps(self):
         """Get a text representation of the computation graph.
 
-        Usually the graphical representation provided by ``draw_graph`` or
-        ``full_report`` is more useful. This is a fallback for inspecting the
-        computation graph when only text output is available.
+        Usually the graphical representation provided by :meth:`Expr.skb.draw_graph` or
+        :meth:`Expr.skb.full_report` is more useful. This is a fallback for inspecting
+        the computation graph when only text output is available.
 
         Returns
         -------
@@ -1125,6 +1125,14 @@ class SkrubNamespace:
             A string representing the different computation steps, one on each
             line.
 
+        See Also
+        --------
+        :func:`sklearn.model_selection.cross_validate`:
+            Evaluate metric(s) by cross-validation and also record fit/score times.
+
+        :func:`skrub.Expr.skb.get_pipeline`:
+            Get a skrub pipeline for this expression.
+
         Examples
         --------
         >>> import skrub
@@ -1483,7 +1491,7 @@ class SkrubNamespace:
 
         splitter : function, optional
             The function used to split X and y once they have been computed. By
-            default, ``sklearn.train_test_split`` is used.
+            default, :func:`~sklearn.model_selection.train_test_split` is used.
 
         splitter_kwargs
             Additional named arguments to pass to the splitter.
@@ -1546,7 +1554,7 @@ class SkrubNamespace:
         """Find the best parameters with grid search.
 
         This function returns a :class:`ParamSearch`, an object similar to
-        scikit-learn's ``GridSearchCV``, where the main difference is that
+        scikit-learn's :class:`~sklearn.model_selection.RandomizedSearchCV`, where the main difference is that
         ``fit()`` and ``predict()`` accept a dictionary of inputs
         rather than ``X`` and ``y``. The best pipeline can
         be returned by calling ``.best_pipeline_``.
@@ -1974,7 +1982,7 @@ class SkrubNamespace:
         """Mark this expression as being the ``X`` table.
 
         This is used for cross-validation and hyperparameter selection: operations
-        done before ``.skb.mark_as_X()`` and ``.skb.mark_as_y()`` are executed
+        done before :meth:`.skb.mark_as_X()` and :meth:`.skb.mark_as_y()` are executed
         on the entire data and cannot benefit from hyperparameter tuning.
         Returns a copy; the original expression is left unchanged.
 
@@ -1982,6 +1990,12 @@ class SkrubNamespace:
         -------
         The input expression, which has been marked as being ``X``
 
+        See also
+        --------
+        :func:`skrub.X`
+            ``skrub.X(value)`` can be used as a shorthand for
+            ``skrub.var('X', value).skb.mark_as_X()``.
+
         Notes
         -----
         During cross-validation, all the previous steps are first executed,
@@ -2042,7 +2056,7 @@ class SkrubNamespace:
 
     @property
     def is_X(self):
-        """Whether this expression has been marked with ``.skb.mark_as_X()``."""
+        """Whether this expression has been marked with :meth:`.skb.mark_as_X()`."""
         return self._expr._skrub_impl.is_X
 
     @check_expr
@@ -2050,7 +2064,7 @@ class SkrubNamespace:
         """Mark this expression as being the ``y`` table.
 
         This is used for cross-validation and hyperparameter selection: operations
-        done before ``.skb.mark_as_X()`` and ``.skb.mark_as_y()`` are executed
+        done before :meth:`.skb.mark_as_X()` and :meth:`.skb.mark_as_y()` are executed
         on the entire data and cannot benefit from hyperparameter tuning.
         Returns a copy; the original expression is left unchanged.
 
@@ -2071,11 +2085,14 @@ class SkrubNamespace:
         should be careful to start our pipeline by building X and y, and to use
         ``mark_as_X()`` and ``mark_as_y()`` as soon as possible.
 
-        ``skrub.y(value)`` can be used as a shorthand for
-        ``skrub.var('y', value).skb.mark_as_y()``.
-
         Note: this marks the expression in-place and also returns it.
 
+        See also
+        --------
+        :func:`skrub.y`
+            ``skrub.y(value)`` can be used as a shorthand for
+            ``skrub.var('y', value).skb.mark_as_y()``.
+
         Examples
         --------
         >>> import skrub
@@ -2115,7 +2132,7 @@ class SkrubNamespace:
 
     @property
     def is_y(self):
-        """Whether this expression has been marked with ``.skb.mark_as_y()``."""
+        """Whether this expression has been marked with :meth:`.skb.mark_as_y()`."""
         return self._expr._skrub_impl.is_y
 
     @check_expr
@@ -2263,14 +2280,14 @@ class SkrubNamespace:
         <AppliedEstimator>
         Result:
         ―――――――
-        OnSubFrame(transformer=TableVectorizer())
+        ApplyToFrame(transformer=TableVectorizer())
 
         Note that in order to restrict transformers to a subset of columns,
-        they will be wrapped in a meta-estimator ``OnSubFrame`` or
-        ``OnEachColumn`` depending if the transformer is applied to each column
+        they will be wrapped in a meta-estimator ``ApplyToFrame`` or
+        ``ApplyToCols`` depending if the transformer is applied to each column
         separately or not. The actual transformer can be retrieved through the
-        ``transformer_`` attribute of ``OnSubFrame`` or ``transformers_``
-        attribute of ``OnEachColumn`` (a dictionary mapping column names to the
+        ``transformer_`` attribute of ``ApplyToFrame`` or ``transformers_``
+        attribute of ``ApplyToCols`` (a dictionary mapping column names to the
         corresponding transformer).
 
         >>> fitted_vectorizer.transformer_
@@ -2297,7 +2314,7 @@ class SkrubNamespace:
         <AppliedEstimator>
         Result:
         ―――――――
-        OnEachColumn(cols=(string() - cols('date')),
+        ApplyToCols(cols=(string() - cols('date')),
                      transformer=StringEncoder(n_components=2))
         >>> fitted_vectorizer.transformers_
         <GetAttr 'transformers_'>
diff --git a/skrub/_gap_encoder.py b/skrub/_gap_encoder.py
index 99e81db..6b9fb48 100644
--- a/skrub/_gap_encoder.py
+++ b/skrub/_gap_encoder.py
@@ -1,6 +1,7 @@
 """
 Implements the GapEncoder: a probabilistic encoder for categorical variables.
 """
+
 from __future__ import annotations
 
 from copy import deepcopy
@@ -18,7 +19,7 @@ from sklearn.utils.extmath import row_norms, safe_sparse_dot
 from sklearn.utils.validation import _num_samples, check_is_fitted
 
 from . import _dataframe as sbd
-from ._on_each_column import RejectColumn, SingleColumnTransformer
+from ._apply_to_cols import RejectColumn, SingleColumnTransformer
 from ._utils import unique_strings
 
 
diff --git a/skrub/_minhash_encoder.py b/skrub/_minhash_encoder.py
index 625a170..c079b8f 100644
--- a/skrub/_minhash_encoder.py
+++ b/skrub/_minhash_encoder.py
@@ -12,8 +12,8 @@ from sklearn.utils import gen_even_slices, murmurhash3_32
 from sklearn.utils.validation import check_is_fitted
 
 from . import _dataframe as sbd
+from ._apply_to_cols import RejectColumn, SingleColumnTransformer
 from ._fast_hash import ngram_min_hash
-from ._on_each_column import RejectColumn, SingleColumnTransformer
 from ._string_distances import get_unique_ngrams
 from ._utils import LRUDict, unique_strings
 
diff --git a/skrub/_reporting/_data/templates/column-summaries.html b/skrub/_reporting/_data/templates/column-summaries.html
index f276be9..7cf599f 100644
--- a/skrub/_reporting/_data/templates/column-summaries.html
+++ b/skrub/_reporting/_data/templates/column-summaries.html
@@ -25,8 +25,9 @@
     <div id="skipped-plot-alert" class="alert-info-dismissable flex space-between"
         data-manager="AlertDismissable">
         <div class="alert-content shrinkable-text">
-            Plotting was skipped because the dataframe has many columns.
-            See TableReport's <code>max_plot_columns</code> parameter.
+            Plotting was skipped as the dataframe exceeded the
+            <a href="https://skrub-data.org/stable/reference/generated/skrub.TableReport.html"><code>max_plot_columns</code></a>
+            limit set for the TableReport during report creation.
         </div>
         {{buttons.dismissbutton()}}
     </div>
diff --git a/skrub/_select_cols.py b/skrub/_select_cols.py
index 8e6fbcd..fd5cf45 100644
--- a/skrub/_select_cols.py
+++ b/skrub/_select_cols.py
@@ -1,7 +1,7 @@
-from sklearn.base import BaseEstimator, TransformerMixin
+from sklearn.base import BaseEstimator, TransformerMixin, check_is_fitted
 
 from . import selectors as s
-from ._on_each_column import SingleColumnTransformer
+from ._apply_to_cols import SingleColumnTransformer
 
 
 class SelectCols(TransformerMixin, BaseEstimator):
@@ -77,6 +77,17 @@ class SelectCols(TransformerMixin, BaseEstimator):
         """
         return s.select(X, self._columns)
 
+    def get_feature_names_out(self):
+        """Get output feature names for transformation.
+
+        Returns
+        -------
+        feature_names_out : ndarray of str objects
+            Transformed feature names.
+        """
+        check_is_fitted(self, "_columns")
+        return self._columns
+
 
 class DropCols(TransformerMixin, BaseEstimator):
     """Drop a subset of a DataFrame's columns.
@@ -151,6 +162,17 @@ class DropCols(TransformerMixin, BaseEstimator):
         """
         return s.select(X, ~s.make_selector(self._columns))
 
+    def get_feature_names_out(self):
+        """Get output feature names for transformation.
+
+        Returns
+        -------
+        feature_names_out : ndarray of str objects
+            Transformed feature names.
+        """
+        check_is_fitted(self, "_columns")
+        return self._columns
+
 
 class Drop(SingleColumnTransformer):
     def fit_transform(self, column, y=None):
diff --git a/skrub/_string_encoder.py b/skrub/_string_encoder.py
index 0e7873b..9d15de6 100644
--- a/skrub/_string_encoder.py
+++ b/skrub/_string_encoder.py
@@ -9,7 +9,7 @@ from sklearn.feature_extraction.text import (
 from sklearn.pipeline import Pipeline
 
 from . import _dataframe as sbd
-from ._on_each_column import SingleColumnTransformer
+from ._apply_to_cols import SingleColumnTransformer
 from ._scaling_factor import scaling_factor
 from ._to_str import ToStr
 
diff --git a/skrub/_table_vectorizer.py b/skrub/_table_vectorizer.py
index 13dcb39..324eecf 100644
--- a/skrub/_table_vectorizer.py
+++ b/skrub/_table_vectorizer.py
@@ -11,12 +11,12 @@ from sklearn.utils.validation import check_is_fitted
 from . import _dataframe as sbd
 from . import _utils
 from . import selectors as s
+from ._apply_to_cols import SingleColumnTransformer
 from ._check_input import CheckInputDataFrame
 from ._clean_categories import CleanCategories
 from ._clean_null_strings import CleanNullStrings
 from ._datetime_encoder import DatetimeEncoder
 from ._drop_uninformative import DropUninformative
-from ._on_each_column import SingleColumnTransformer
 from ._select_cols import Drop
 from ._sklearn_compat import _VisualBlock
 from ._string_encoder import StringEncoder
@@ -75,7 +75,7 @@ def _created_by_predicate(col, transformers):
 def _created_by(*transformers):
     """Selector for columns created by one of the provided transformers.
 
-    Each of ``transformers`` must be an instance of ``OnEachColumn``.
+    Each of ``transformers`` must be an instance of ``ApplyToCols``.
     A column is matched if it was created (or modified) by one of them, i.e. if
     it is listed in one of their ``created_outputs_`` fitted attributes.
 
diff --git a/skrub/_text_encoder.py b/skrub/_text_encoder.py
index 9647b94..2c0f734 100644
--- a/skrub/_text_encoder.py
+++ b/skrub/_text_encoder.py
@@ -9,7 +9,7 @@ from sklearn.decomposition import PCA
 from sklearn.utils.validation import check_is_fitted
 
 from . import _dataframe as sbd
-from ._on_each_column import SingleColumnTransformer
+from ._apply_to_cols import SingleColumnTransformer
 from ._scaling_factor import scaling_factor
 from ._to_str import ToStr
 from ._utils import import_optional_dependency, unique_strings
diff --git a/skrub/_to_categorical.py b/skrub/_to_categorical.py
index ab18d52..99fe262 100644
--- a/skrub/_to_categorical.py
+++ b/skrub/_to_categorical.py
@@ -1,5 +1,5 @@
 from . import _dataframe as sbd
-from ._on_each_column import RejectColumn, SingleColumnTransformer
+from ._apply_to_cols import RejectColumn, SingleColumnTransformer
 
 __all__ = ["ToCategorical"]
 
@@ -86,7 +86,7 @@ class ToCategorical(SingleColumnTransformer):
     >>> to_cat.fit_transform(pd.Series([1.1, 2.2], name='c'))
     Traceback (most recent call last):
         ...
-    skrub._on_each_column.RejectColumn: Column 'c' does not contain strings.
+    skrub._apply_to_cols.RejectColumn: Column 'c' does not contain strings.
 
     ``object`` columns that do not contain only strings are also rejected:
 
@@ -94,7 +94,7 @@ class ToCategorical(SingleColumnTransformer):
     >>> to_cat.fit_transform(s)
     Traceback (most recent call last):
         ...
-    skrub._on_each_column.RejectColumn: Column 'c' does not contain strings.
+    skrub._apply_to_cols.RejectColumn: Column 'c' does not contain strings.
 
     No special handling of ``StringDtype`` vs ``object`` columns is done, the
     behavior is the same as ``pd.astype('category')``: if the input uses the
diff --git a/skrub/_to_datetime.py b/skrub/_to_datetime.py
index 10d77ee..23efb1b 100644
--- a/skrub/_to_datetime.py
+++ b/skrub/_to_datetime.py
@@ -7,9 +7,9 @@ from sklearn.utils.validation import check_is_fitted
 
 from . import _dataframe as sbd
 from . import selectors as s
+from ._apply_to_cols import RejectColumn, SingleColumnTransformer
 from ._dataframe._common import _raise as _sbd_raise
 from ._dispatch import dispatch
-from ._on_each_column import RejectColumn, SingleColumnTransformer
 from ._wrap_transformer import wrap_transformer
 
 __all__ = ["ToDatetime", "to_datetime"]
@@ -176,7 +176,7 @@ class ToDatetime(SingleColumnTransformer):
     >>> ToDatetime(format="%d/%m/%Y").fit_transform(s)
     Traceback (most recent call last):
         ...
-    skrub._on_each_column.RejectColumn: Failed to convert column 'when' to datetimes using the format '%d/%m/%Y'.
+    skrub._apply_to_cols.RejectColumn: Failed to convert column 'when' to datetimes using the format '%d/%m/%Y'.
 
     Columns that already have ``Datetime`` ``dtype`` are not modified (but
     they are accepted); for those columns the provided format, if any, is ignored.
@@ -206,7 +206,7 @@ class ToDatetime(SingleColumnTransformer):
     >>> to_dt.fit_transform(s)
     Traceback (most recent call last):
         ...
-    skrub._on_each_column.RejectColumn: Column 'year' does not contain strings.
+    skrub._apply_to_cols.RejectColumn: Column 'year' does not contain strings.
 
     String columns that do not appear to contain datetimes or for some other reason
     fail to be converted are also rejected.
@@ -215,7 +215,7 @@ class ToDatetime(SingleColumnTransformer):
     >>> to_dt.fit_transform(s)
     Traceback (most recent call last):
         ...
-    skrub._on_each_column.RejectColumn: Could not find a datetime format for column 'when'.
+    skrub._apply_to_cols.RejectColumn: Could not find a datetime format for column 'when'.
 
     Once ``ToDatetime`` was successfully fitted, ``transform`` will always try to
     parse datetimes with the same format and output the same ``dtype``. Entries that
diff --git a/skrub/_to_float32.py b/skrub/_to_float32.py
index 4cf1185..bb34c94 100644
--- a/skrub/_to_float32.py
+++ b/skrub/_to_float32.py
@@ -1,5 +1,5 @@
 from . import _dataframe as sbd
-from ._on_each_column import RejectColumn, SingleColumnTransformer
+from ._apply_to_cols import RejectColumn, SingleColumnTransformer
 
 __all__ = ["ToFloat32"]
 
@@ -132,7 +132,7 @@ class ToFloat32(SingleColumnTransformer):
     >>> to_float.fit_transform(s)
     Traceback (most recent call last):
         ...
-    skrub._on_each_column.RejectColumn: Could not convert column 'x' to numbers.
+    skrub._apply_to_cols.RejectColumn: Could not convert column 'x' to numbers.
 
     Once a column has been accepted, all calls to ``transform`` will result in the
     same output dtype. Values that fail to be converted become null values.
@@ -154,11 +154,11 @@ class ToFloat32(SingleColumnTransformer):
     >>> to_float.fit_transform(s)
     Traceback (most recent call last):
         ...
-    skrub._on_each_column.RejectColumn: Refusing to cast column 's' with dtype 'category' to numbers.
+    skrub._apply_to_cols.RejectColumn: Refusing to cast column 's' with dtype 'category' to numbers.
     >>> to_float.fit_transform(pd.to_datetime(pd.Series(['2024-05-13'], name='s')))
     Traceback (most recent call last):
         ...
-    skrub._on_each_column.RejectColumn: Refusing to cast column 's' with dtype 'datetime64[...]' to numbers.
+    skrub._apply_to_cols.RejectColumn: Refusing to cast column 's' with dtype 'datetime64[...]' to numbers.
 
     float32 columns are passed through:
 
diff --git a/skrub/_to_str.py b/skrub/_to_str.py
index 1312bbd..6f7e3bc 100644
--- a/skrub/_to_str.py
+++ b/skrub/_to_str.py
@@ -1,5 +1,5 @@
 from . import _dataframe as sbd
-from ._on_each_column import RejectColumn, SingleColumnTransformer
+from ._apply_to_cols import RejectColumn, SingleColumnTransformer
 
 __all__ = ["ToStr"]
 
@@ -89,15 +89,15 @@ class ToStr(SingleColumnTransformer):
     >>> to_str.fit_transform(pd.Series([1.1, 2.2], name='s'))
     Traceback (most recent call last):
         ...
-    skrub._on_each_column.RejectColumn: Refusing to convert 's' with dtype 'float64' to strings.
+    skrub._apply_to_cols.RejectColumn: Refusing to convert 's' with dtype 'float64' to strings.
     >>> to_str.fit_transform(pd.Series(['a', 'b'], name='s', dtype='category'))
     Traceback (most recent call last):
         ...
-    skrub._on_each_column.RejectColumn: Refusing to convert 's' with dtype 'category' to strings.
+    skrub._apply_to_cols.RejectColumn: Refusing to convert 's' with dtype 'category' to strings.
     >>> to_str.fit_transform(pd.to_datetime(pd.Series(['2020-02-02'])))
     Traceback (most recent call last):
         ...
-    skrub._on_each_column.RejectColumn: Refusing to convert None with dtype 'datetime64[...]' to strings.
+    skrub._apply_to_cols.RejectColumn: Refusing to convert None with dtype 'datetime64[...]' to strings.
 
     However, once a column has been accepted, the output of ``transform`` will
     always be strings:
@@ -163,11 +163,11 @@ class ToStr(SingleColumnTransformer):
     >>> to_str.fit_transform(pl.Series('s', ['a', 'b'], dtype=pl.Enum(['a', 'b'])))
     Traceback (most recent call last):
         ...
-    skrub._on_each_column.RejectColumn: Refusing to convert 's' with dtype 'Enum(categories=['a', 'b'])' to strings.
+    skrub._apply_to_cols.RejectColumn: Refusing to convert 's' with dtype 'Enum(categories=['a', 'b'])' to strings.
     >>> to_str.fit_transform(pl.Series('s', ['2020-02-01']).cast(pl.Date))
     Traceback (most recent call last):
         ...
-    skrub._on_each_column.RejectColumn: Refusing to convert 's' with dtype 'Date' to strings.
+    skrub._apply_to_cols.RejectColumn: Refusing to convert 's' with dtype 'Date' to strings.
 
     If ``convert_category=True``, Categorical columns are converted:
     >>> to_str = ToStr(convert_category=True)
diff --git a/skrub/_wrap_transformer.py b/skrub/_wrap_transformer.py
index b36250d..fc6adcd 100644
--- a/skrub/_wrap_transformer.py
+++ b/skrub/_wrap_transformer.py
@@ -1,5 +1,5 @@
-from ._on_each_column import OnEachColumn
-from ._on_subframe import OnSubFrame
+from ._apply_to_cols import ApplyToCols
+from ._apply_to_frame import ApplyToFrame
 from .selectors import make_selector
 
 __all__ = ["wrap_transformer"]
@@ -14,19 +14,19 @@ def wrap_transformer(
     n_jobs=None,
     columnwise="auto",
 ):
-    """Create a ``OnEachColumn`` or a ``OnSubFrame`` transformer.
+    """Create a ``ApplyToCols`` or a ``ApplyToFrame`` transformer.
 
     The ``transformer`` is wrapped in a transformer that will apply it to part
     of the input dataframe.
 
     By default, if ``transformer`` is a single-column transformer
     (has a ``__single_column_transformer__`` attribute), it is wrapped in a
-    ``OnEachColumn`` instance. Otherwise it is wrapped in a ``OnSubFrame``
+    ``ApplyToCols`` instance. Otherwise it is wrapped in a ``ApplyToFrame``
     instance.
 
     This default choice can be overridden by passing ``columnwise=True`` to
-    force the use of ``OnEachColumn`` or ``columnwise=False`` to force the use
-    of ``OnSubFrame``.
+    force the use of ``ApplyToCols`` or ``columnwise=False`` to force the use
+    of ``ApplyToFrame``.
 
     Parameters
     ----------
@@ -38,26 +38,26 @@ def wrap_transformer(
 
     allow_reject : bool, default=False
         Whether to allow column rejections. Only used when the result is an
-        instance of ``OnEachColumn``, see this class' docstring for details.
+        instance of ``ApplyToCols``, see this class' docstring for details.
 
     keep_original : bool, default=False
         Whether to retain the original columns in transformed output. See the
-        documentation of ``OnEachColumn`` or ``OnSubFrame`` for details.
+        documentation of ``ApplyToCols`` or ``ApplyToFrame`` for details.
 
     rename_columns : str, default='{}'
         Format string applied to output column names. See the documentation of
-        ``OnEachColumn`` or ``OnSubFrame`` for details.
+        ``ApplyToCols`` or ``ApplyToFrame`` for details.
 
     n_jobs : int, default=None
         Number of jobs to run in parallel. Only used when the result is an
-        instance of ``OnEachColumn``, see this class' docstring for details.
+        instance of ``ApplyToCols``, see this class' docstring for details.
 
     columnwise : 'auto' or bool, default='auto'
-        Whether to create a ``OnEachColumn`` or ``OnSubFrame`` instance. By
-        default, ``OnEachColumn`` is used if ``transformer`` has a
-        ``__single_column_transformer__`` attribute and ``OnSubFrame``
-        otherwise. Pass ``columnwise=True`` to force using ``OnEachColumn`` and
-        ``columnwise=False`` to force using ``OnSubFrame``. Note that forcing
+        Whether to create a ``ApplyToCols`` or ``ApplyToFrame`` instance. By
+        default, ``ApplyToCols`` is used if ``transformer`` has a
+        ``__single_column_transformer__`` attribute and ``ApplyToFrame``
+        otherwise. Pass ``columnwise=True`` to force using ``ApplyToCols`` and
+        ``columnwise=False`` to force using ``ApplyToFrame``. Note that forcing
         ``columnwise=False`` for a single-column transformer will most likely
         cause an error during ``fit``, and forcing ``columnwise=True`` for a
         regular transformer is only appropriate if the transformer can be
@@ -67,7 +67,7 @@ def wrap_transformer(
     Returns
     -------
     Wrapped transformer
-        A ``OnEachColumn`` or ``OnSubFrame`` instance initialized with the
+        A ``ApplyToCols`` or ``ApplyToFrame`` instance initialized with the
         input ``transformer``.
 
     Examples
@@ -78,11 +78,11 @@ def wrap_transformer(
     >>> from sklearn.preprocessing import OrdinalEncoder
 
     >>> wrap_transformer(ToDatetime(), s.all())
-    OnEachColumn(transformer=ToDatetime())
+    ApplyToCols(transformer=ToDatetime())
     >>> wrap_transformer(OrdinalEncoder(), s.string())
-    OnSubFrame(cols=string(), transformer=OrdinalEncoder())
+    ApplyToFrame(cols=string(), transformer=OrdinalEncoder())
     >>> wrap_transformer(OrdinalEncoder(), s.string(), columnwise=True, n_jobs=4)
-    OnEachColumn(cols=string(), n_jobs=4, transformer=OrdinalEncoder())
+    ApplyToCols(cols=string(), n_jobs=4, transformer=OrdinalEncoder())
     """
     selector = make_selector(selector)
 
@@ -90,7 +90,7 @@ def wrap_transformer(
         columnwise = hasattr(transformer, "__single_column_transformer__")
 
     if columnwise:
-        return OnEachColumn(
+        return ApplyToCols(
             transformer,
             cols=selector,
             allow_reject=allow_reject,
@@ -98,7 +98,7 @@ def wrap_transformer(
             rename_columns=rename_columns,
             n_jobs=n_jobs,
         )
-    return OnSubFrame(
+    return ApplyToFrame(
         transformer,
         cols=selector,
         keep_original=keep_original,
diff --git a/skrub/datasets/_fetching.py b/skrub/datasets/_fetching.py
index daa0a18..a9051cb 100644
--- a/skrub/datasets/_fetching.py
+++ b/skrub/datasets/_fetching.py
@@ -12,7 +12,19 @@ def fetch_employee_salaries(data_home=None):
     Description of the dataset:
         Annual salary information including gross pay and overtime pay for all
         active, permanent employees of Montgomery County, MD paid in calendar
-        year 2016. This information will be published annually each year.
+        year 2016. This dataset is a copy of https://www.openml.org/d/42125
+        where some features are dropped to avoid data leaking.
+
+    .. note::
+
+        Some environments like Jupyterlite can run into networking issues when
+        connecting to a remote server, but OpenML provides CORS headers. To
+        download this dataset using OpenML instead of Github or Figshare, run:
+
+    .. code:: python
+
+        from sklearn.datasets import fetch_openml
+        df = fetch_openml(data_id=42125)
 
     Parameters
     ----------


Test patch:
diff --git a/skrub/_expressions/tests/test_expressions.py b/skrub/_expressions/tests/test_expressions.py
index 430131f..d87d9ec 100644
--- a/skrub/_expressions/tests/test_expressions.py
+++ b/skrub/_expressions/tests/test_expressions.py
@@ -236,7 +236,7 @@ def test_expr_impl():
 def test_apply_bad_params(why_full_frame, 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
-    # full input without wrapping in OnEachColumn or OnSubFrame. In this case
+    # 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.
diff --git a/skrub/_expressions/tests/test_interactive_features.py b/skrub/_expressions/tests/test_interactive_features.py
index ac277c4..9bf78b1 100644
--- a/skrub/_expressions/tests/test_interactive_features.py
+++ b/skrub/_expressions/tests/test_interactive_features.py
@@ -209,7 +209,7 @@ def test_repr():
     >>> X.skb.concat(skrub.as_expr([X, X]),axis=0)
     <Concat>
 
-    if we end up applying a OnEachColumn, seeing the inner transformer is more
+    if we end up applying a ApplyToCols, seeing the inner transformer is more
     informative.
 
     >>> from skrub._wrap_transformer import wrap_transformer
diff --git a/skrub/tests/test_clean_categories.py b/skrub/tests/test_clean_categories.py
index 2696162..666ec44 100644
--- a/skrub/tests/test_clean_categories.py
+++ b/skrub/tests/test_clean_categories.py
@@ -4,8 +4,8 @@ import pytest
 from pandas.testing import assert_series_equal
 
 from skrub import _dataframe as sbd
+from skrub._apply_to_cols import RejectColumn
 from skrub._clean_categories import CleanCategories, _with_string_categories
-from skrub._on_each_column import RejectColumn
 
 
 def test_clean_categories_polars():
diff --git a/skrub/tests/test_clean_null_strings.py b/skrub/tests/test_clean_null_strings.py
index bfcc826..e151cc7 100644
--- a/skrub/tests/test_clean_null_strings.py
+++ b/skrub/tests/test_clean_null_strings.py
@@ -1,8 +1,8 @@
 import numpy as np
 import pytest
 
+from skrub._apply_to_cols import RejectColumn
 from skrub._clean_null_strings import CleanNullStrings, _trim_whitespace_only
-from skrub._on_each_column import RejectColumn
 
 
 def test_clean_null_strings(df_module):
diff --git a/skrub/tests/test_datetime_encoder.py b/skrub/tests/test_datetime_encoder.py
index b03afa3..a9f51c0 100644
--- a/skrub/tests/test_datetime_encoder.py
+++ b/skrub/tests/test_datetime_encoder.py
@@ -4,7 +4,7 @@ from types import SimpleNamespace
 import numpy as np
 import pytest
 
-from skrub import DatetimeEncoder
+from skrub import ApplyToCols, DatetimeEncoder
 from skrub import _dataframe as sbd
 from skrub import selectors as s
 from skrub._datetime_encoder import (
@@ -13,7 +13,6 @@ from skrub._datetime_encoder import (
     _is_date,
     _SplineEncoder,
 )
-from skrub._on_each_column import OnEachColumn
 from skrub._to_float32 import ToFloat32
 
 
@@ -116,7 +115,7 @@ def expected_features(df_module):
     }
 
     res = df_module.make_dataframe(values)
-    return OnEachColumn(ToFloat32()).fit_transform(res)
+    return ApplyToCols(ToFloat32()).fit_transform(res)
 
 
 def test_fit_transform(a_datetime_col, expected_features, df_module, use_fit_transform):
diff --git a/skrub/tests/test_gap_encoder.py b/skrub/tests/test_gap_encoder.py
index 43fdc6a..8d98bb2 100644
--- a/skrub/tests/test_gap_encoder.py
+++ b/skrub/tests/test_gap_encoder.py
@@ -9,7 +9,7 @@ from sklearn.model_selection import train_test_split
 
 from skrub import GapEncoder
 from skrub import _dataframe as sbd
-from skrub._on_each_column import RejectColumn
+from skrub._apply_to_cols import RejectColumn
 from skrub.datasets import fetch_midwest_survey
 from skrub.tests.utils import generate_data as _gen_data
 
diff --git a/skrub/tests/test_on_each_column.py b/skrub/tests/test_on_each_column.py
index 6a09c32..f09aa46 100644
--- a/skrub/tests/test_on_each_column.py
+++ b/skrub/tests/test_on_each_column.py
@@ -10,7 +10,11 @@ from sklearn.preprocessing import OneHotEncoder
 
 from skrub import _dataframe as sbd
 from skrub import selectors as s
-from skrub._on_each_column import OnEachColumn, RejectColumn, SingleColumnTransformer
+from skrub._apply_to_cols import (
+    ApplyToCols,
+    RejectColumn,
+    SingleColumnTransformer,
+)
 from skrub._select_cols import Drop
 
 
@@ -75,11 +79,11 @@ def test_single_column_transformer_attribute():
 
 
 class Mult(BaseEstimator):
-    """Dummy to test the different kinds of output supported by OnEachColumn.
+    """Dummy to test the different kinds of output supported by ApplyToCols.
 
     Supported kinds of output are a single column, a list of columns, or a
     dataframe. This also checks that when the transformer is not a
-    single-column transformer, X is passed by OnEachColumn as a dataframe
+    single-column transformer, X is passed by ApplyToCols as a dataframe
     containing a single column (not as a column).
     """
 
@@ -123,8 +127,10 @@ class SingleColMult(Mult):
 
 @pytest.mark.parametrize("output_kind", ["single_column", "dataframe", "column_list"])
 @pytest.mark.parametrize("transformer_class", [Mult, SingleColMult])
-def test_on_each_column(df_module, output_kind, transformer_class, use_fit_transform):
-    mapper = OnEachColumn(transformer_class(output_kind), s.glob("a*"))
+def test_single_column_transformer(
+    df_module, output_kind, transformer_class, use_fit_transform
+):
+    mapper = ApplyToCols(transformer_class(output_kind), s.glob("a*"))
     X = df_module.make_dataframe(
         {"a 0": [1.0, 2.2], "a 1": [3.0, 4.4], "b": [5.0, 6.6]}
     )
@@ -149,14 +155,14 @@ def test_on_each_column(df_module, output_kind, transformer_class, use_fit_trans
 
 
 def test_empty_selection(df_module):
-    mapper = OnEachColumn(Drop(), ())
+    mapper = ApplyToCols(Drop(), ())
     out = mapper.fit_transform(df_module.example_dataframe)
     df_module.assert_frame_equal(out, df_module.example_dataframe)
     assert mapper.transformers_ == {}
 
 
 def test_empty_output(df_module):
-    mapper = OnEachColumn(Drop())
+    mapper = ApplyToCols(Drop())
     out = mapper.fit_transform(df_module.example_dataframe)
     if df_module.name == "pandas":
         expected = df_module.empty_dataframe.set_axis(
@@ -171,7 +177,7 @@ def test_empty_output(df_module):
 
 
 class Rejector(SingleColumnTransformer):
-    """Dummy class to test OnEachColumn behavior when columns are rejected."""
+    """Dummy class to test ApplyToCols behavior when columns are rejected."""
 
     def fit_transform(self, column, y=None):
         if sbd.name(column) != "float-col":
@@ -184,7 +190,7 @@ class Rejector(SingleColumnTransformer):
 
 def test_allowed_column_rejections(df_module, use_fit_transform):
     df = df_module.example_dataframe
-    mapper = OnEachColumn(Rejector(), allow_reject=True)
+    mapper = ApplyToCols(Rejector(), allow_reject=True)
     if use_fit_transform:
         out = mapper.fit_transform(df)
     else:
@@ -200,7 +206,7 @@ def test_allowed_column_rejections(df_module, use_fit_transform):
 
 def test_forbidden_column_rejections(df_module):
     df = df_module.example_dataframe
-    mapper = OnEachColumn(Rejector())
+    mapper = ApplyToCols(Rejector())
     with pytest.raises(ValueError, match=".*failed on.*int-col"):
         mapper.fit(df)
 
@@ -215,7 +221,7 @@ class RejectInTransform(SingleColumnTransformer):
 
 def test_rejection_forbidden_in_transform(df_module):
     df = df_module.example_dataframe
-    mapper = OnEachColumn(RejectInTransform(), allow_reject=True)
+    mapper = ApplyToCols(RejectInTransform(), allow_reject=True)
     mapper.fit(df)
     with pytest.raises(ValueError, match=".*failed on.*int-col"):
         mapper.transform(df)
@@ -249,15 +255,15 @@ def test_column_renaming(df_module, use_fit_transform):
 
     df = df_module.make_dataframe({"A": [1], "B": [2]})
 
-    mapper = OnEachColumn(RenameB())
+    mapper = ApplyToCols(RenameB())
     fit_transform()
     assert out_names == ["B__skrub_XXX__", "B"]
 
-    mapper = OnEachColumn(RenameB(), cols=("A",), rename_columns="{}_out")
+    mapper = ApplyToCols(RenameB(), cols=("A",), rename_columns="{}_out")
     fit_transform()
     assert out_names == ["B_out", "B"]
 
-    mapper = OnEachColumn(
+    mapper = ApplyToCols(
         RenameB(), cols=("A",), rename_columns="{}_out", keep_original=True
     )
     fit_transform()
@@ -271,7 +277,7 @@ class NumpyOutput(BaseEstimator):
 
 def test_wrong_transformer_output_type(pd_module):
     with pytest.raises(TypeError, match=".*fit_transform returned a result of type"):
-        OnEachColumn(NumpyOutput()).fit_transform(pd_module.example_dataframe)
+        ApplyToCols(NumpyOutput()).fit_transform(pd_module.example_dataframe)
 
 
 def test_set_output_failure(df_module):
@@ -282,7 +288,7 @@ def test_set_output_failure(df_module):
         {"a 0": [1.0, 2.2], "a 1": [3.0, 4.4], "b": [5.0, 6.6]}
     )
     y = [0.0, 1.0]
-    mapper = OnEachColumn(make_pipeline(Mult()))
+    mapper = ApplyToCols(make_pipeline(Mult()))
     mapper.fit_transform(X, y)
 
 
@@ -297,7 +303,7 @@ class ResetsIndex(BaseEstimator):
 @pytest.mark.parametrize("cols", [(), ("a",), ("a", "b")])
 def test_output_index(cols):
     df = pd.DataFrame({"a": [10, 20], "b": [1.1, 2.2]}, index=[-1, -2])
-    transformer = OnEachColumn(ResetsIndex(), cols=cols)
+    transformer = ApplyToCols(ResetsIndex(), cols=cols)
     assert_index_equal(transformer.fit_transform(df).index, df.index)
     df = pd.DataFrame({"a": [10, 20], "b": [1.1, 2.2]}, index=[-10, 20])
     assert_index_equal(transformer.transform(df).index, df.index)
@@ -308,4 +314,4 @@ def test_set_output_polars(pl_module):
     # would cause a failure in old scikit-learn versions.
     # see #1122 for details
     df = pl_module.make_dataframe({"x": ["a", "b", "c"]})
-    OnEachColumn(OneHotEncoder(sparse_output=False)).fit_transform(df)
+    ApplyToCols(OneHotEncoder(sparse_output=False)).fit_transform(df)
diff --git a/skrub/tests/test_on_subframe.py b/skrub/tests/test_on_subframe.py
index 3ffa993..04b1eaf 100644
--- a/skrub/tests/test_on_subframe.py
+++ b/skrub/tests/test_on_subframe.py
@@ -10,7 +10,7 @@ from sklearn.preprocessing import FunctionTransformer
 from skrub import SelectCols
 from skrub import _dataframe as sbd
 from skrub import selectors as s
-from skrub._on_subframe import OnSubFrame
+from skrub._apply_to_frame import ApplyToFrame
 
 
 class Dummy(BaseEstimator):
@@ -34,7 +34,7 @@ class Dummy(BaseEstimator):
 def test_on_subframe(df_module, use_fit_transform):
     X = df_module.make_dataframe({"a": [1.0, 2.2], "b": [3.0, 4.4], "c": [5.0, 6.6]})
     y = [0.0, 1.0]
-    transformer = OnSubFrame(Dummy(), ["a", "c"])
+    transformer = ApplyToFrame(Dummy(), ["a", "c"])
     if use_fit_transform:
         out = transformer.fit_transform(X, y)
     else:
@@ -53,7 +53,7 @@ def test_on_subframe(df_module, use_fit_transform):
 
 def test_empty_selection(df_module, use_fit_transform):
     df = df_module.example_dataframe
-    transformer = OnSubFrame(Dummy(), ())
+    transformer = ApplyToFrame(Dummy(), ())
     if use_fit_transform:
         out = transformer.fit_transform(df)
     else:
@@ -62,8 +62,10 @@ def test_empty_selection(df_module, use_fit_transform):
 
 
 def test_empty_output(df_module, use_fit_transform):
+    if df_module.name == "polars":
+        pytest.xfail("Polars need at least one array to concatenate.")
     df = df_module.example_dataframe
-    transformer = OnSubFrame(SelectCols(()))
+    transformer = ApplyToFrame(SelectCols(()))
     if use_fit_transform:
         out = transformer.fit_transform(df)
     else:
@@ -78,7 +80,7 @@ def _to_XXX(names):
 
 def test_keep_original(df_module, use_fit_transform):
     df = df_module.make_dataframe({"A": [1], "B": [2]})
-    transformer = OnSubFrame(FunctionTransformer(), keep_original=True)
+    transformer = ApplyToFrame(FunctionTransformer(), keep_original=True)
 
     if use_fit_transform:
         out = transformer.fit_transform(df)
@@ -96,7 +98,7 @@ class NumpyOutput(BaseEstimator):
 
 def test_wrong_transformer_output_type(pd_module):
     with pytest.raises(TypeError, match=".*fit_transform returned a result of type"):
-        OnSubFrame(NumpyOutput()).fit_transform(pd_module.example_dataframe)
+        ApplyToFrame(NumpyOutput()).fit_transform(pd_module.example_dataframe)
 
 
 class ResetsIndex(BaseEstimator):
@@ -110,7 +112,7 @@ class ResetsIndex(BaseEstimator):
 @pytest.mark.parametrize("cols", [(), ("a",), ("a", "b")])
 def test_output_index(cols):
     df = pd.DataFrame({"a": [10, 20], "b": [1.1, 2.2]}, index=[-1, -2])
-    transformer = OnSubFrame(ResetsIndex(), cols=cols)
+    transformer = ApplyToFrame(ResetsIndex(), cols=cols)
     assert_index_equal(transformer.fit_transform(df).index, df.index)
     df = pd.DataFrame({"a": [10, 20], "b": [1.1, 2.2]}, index=[-10, 20])
     assert_index_equal(transformer.transform(df).index, df.index)
diff --git a/skrub/tests/test_select_cols.py b/skrub/tests/test_select_cols.py
index 40018ea..7ca1358 100644
--- a/skrub/tests/test_select_cols.py
+++ b/skrub/tests/test_select_cols.py
@@ -3,6 +3,7 @@ import pandas.testing
 import pytest
 
 from skrub import DropCols, SelectCols
+from skrub import selectors as s
 from skrub._dataframe import _common as ns
 from skrub._select_cols import Drop
 
@@ -61,3 +62,12 @@ def test_drop(df_module):
     col = df_module.example_column
     assert Drop().fit_transform(col) == []
     assert Drop().fit(col).transform(col) == []
+
+
+def test_get_feature_names_out(df_module):
+    df = df_module.example_dataframe
+    select = SelectCols(cols=s.all()).fit(df)
+    assert len(select.get_feature_names_out()) == ns.shape(df)[1]
+
+    drop = DropCols(cols=[]).fit(df)
+    assert drop.get_feature_names_out() == []
diff --git a/skrub/tests/test_string_encoder.py b/skrub/tests/test_string_encoder.py
index f1a0e10..73a2fd4 100644
--- a/skrub/tests/test_string_encoder.py
+++ b/skrub/tests/test_string_encoder.py
@@ -11,7 +11,7 @@ from sklearn.pipeline import Pipeline
 
 from skrub import StringEncoder, TableVectorizer
 from skrub import _dataframe as sbd
-from skrub._on_each_column import RejectColumn
+from skrub._apply_to_cols import RejectColumn
 from skrub._scaling_factor import scaling_factor
 
 
diff --git a/skrub/tests/test_text_encoder.py b/skrub/tests/test_text_encoder.py
index 4165595..2782340 100644
--- a/skrub/tests/test_text_encoder.py
+++ b/skrub/tests/test_text_encoder.py
@@ -7,7 +7,7 @@ from sklearn.base import clone
 
 import skrub._dataframe as sbd
 from skrub import TableVectorizer, TextEncoder
-from skrub._on_each_column import RejectColumn
+from skrub._apply_to_cols import RejectColumn
 from skrub._text_encoder import ModelNotFound
 
 pytest.importorskip("sentence_transformers")
diff --git a/skrub/tests/test_to_categorical.py b/skrub/tests/test_to_categorical.py
index 94cec5b..15d3de0 100644
--- a/skrub/tests/test_to_categorical.py
+++ b/skrub/tests/test_to_categorical.py
@@ -1,7 +1,7 @@
 import pytest
 
 from skrub import _dataframe as sbd
-from skrub._on_each_column import RejectColumn
+from skrub._apply_to_cols import RejectColumn
 from skrub._to_categorical import ToCategorical
 
 
diff --git a/skrub/tests/test_to_datetime.py b/skrub/tests/test_to_datetime.py
index 9f12bd1..e0c97e7 100644
--- a/skrub/tests/test_to_datetime.py
+++ b/skrub/tests/test_to_datetime.py
@@ -6,9 +6,10 @@ import pandas as pd
 import pytest
 from sklearn.utils.fixes import parse_version
 
+from skrub import ApplyToCols
 from skrub import _dataframe as sbd
+from skrub._apply_to_cols import RejectColumn
 from skrub._dispatch import dispatch
-from skrub._on_each_column import OnEachColumn, RejectColumn
 from skrub._to_datetime import (
     ToDatetime,
     _convert_time_zone,
@@ -214,9 +215,7 @@ def test_to_datetime_func(df_module, datetime_col):
     )
     df_module.assert_frame_equal(
         to_datetime(df_module.example_dataframe),
-        OnEachColumn(ToDatetime(), cols=cols).fit_transform(
-            df_module.example_dataframe
-        ),
+        ApplyToCols(ToDatetime(), cols=cols).fit_transform(df_module.example_dataframe),
     )
     float_col = df_module.example_column
     assert sbd.is_float(float_col)
diff --git a/skrub/tests/test_to_float32.py b/skrub/tests/test_to_float32.py
index aa2722c..b9054a7 100644
--- a/skrub/tests/test_to_float32.py
+++ b/skrub/tests/test_to_float32.py
@@ -2,7 +2,7 @@ import numpy as np
 import pytest
 
 from skrub import _dataframe as sbd
-from skrub._on_each_column import RejectColumn
+from skrub._apply_to_cols import RejectColumn
 from skrub._to_categorical import ToCategorical
 from skrub._to_datetime import ToDatetime
 from skrub._to_float32 import ToFloat32
diff --git a/skrub/tests/test_to_str.py b/skrub/tests/test_to_str.py
index d43fb88..d82a972 100644
--- a/skrub/tests/test_to_str.py
+++ b/skrub/tests/test_to_str.py
@@ -3,7 +3,7 @@ import pandas as pd
 import pytest
 
 from skrub import _dataframe as sbd
-from skrub._on_each_column import RejectColumn
+from skrub._apply_to_cols import RejectColumn
 from skrub._to_datetime import ToDatetime
 from skrub._to_str import ToStr
 
diff --git a/skrub/tests/test_wrap_transformer.py b/skrub/tests/test_wrap_transformer.py
index 353d50f..e1dd710 100644
--- a/skrub/tests/test_wrap_transformer.py
+++ b/skrub/tests/test_wrap_transformer.py
@@ -1,16 +1,16 @@
 from sklearn.preprocessing import OrdinalEncoder
 
+from skrub import ApplyToCols
 from skrub import selectors as s
-from skrub._on_each_column import OnEachColumn
-from skrub._on_subframe import OnSubFrame
+from skrub._apply_to_frame import ApplyToFrame
 from skrub._to_datetime import ToDatetime
 from skrub._wrap_transformer import wrap_transformer
 
 
 def test_wrap_transformer():
     t = wrap_transformer(ToDatetime(), s.all())
-    assert isinstance(t, OnEachColumn)
+    assert isinstance(t, ApplyToCols)
     t = wrap_transformer(OrdinalEncoder(), s.all())
-    assert isinstance(t, OnSubFrame)
+    assert isinstance(t, ApplyToFrame)
     t = wrap_transformer(OrdinalEncoder(), s.all(), columnwise=True)
-    assert isinstance(t, OnEachColumn)
+    assert isinstance(t, ApplyToCols)