Repository: skrub-data/skrub
Pull request #1481 (2025-07-01 11:38:21)

Updating intro example for expressions to move to DataOps
Trying to address #1371: "pipelines" are now "data plans", which result in a "learner". "Expressions" are now "Data Operations". 


Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index aab958c..eefa0c5 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -44,6 +44,18 @@ 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>`.
 
+- :func:`datasets.toy_order` is now available to create a toy dataframe and
+  corresponding targets for examples.
+  :pr:`1485` by :user:`Antoine Canaguier-Durand <canag>`.
+
+- :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>`.
+
+- :func:`get_config`, :func:`set_config` and :func:`config_context` now have a new
+  parameter `cardinality_threshold` that controls the threshold value used to warn user if they have
+  high cardinality columns in their dataset. :pr:`1498` by :user:`rouk1 <rouk1>`.
+
 Changes
 -------
 .. warning::
@@ -63,6 +75,11 @@ Changes
 - The `packaging` dependency was removed.
   :pr:`1307` by :user:`Jovan Stojanovic <jovan-stojanovic>`
 
+- :class:`TextEncoder`, :class:`StringEncoder` and :class:`GapEncoder` now compute the
+  total standard deviation norm during training, which is a global constant, and
+  normalize the vector outputs by performing element-wise division on all entries.
+  :pr:`1274` by :user:`Vincent Maladiere <Vincent-Maladiere>`.
+
 - The :class:`DropIfTooManyNulls` transformer has been replaced by the
   :class:`DropUninformative` transformer and will be removed in a future release.
   :pr:`1313` by :user:`Riccardo Cappuzzo<rcap107>`
@@ -91,6 +108,14 @@ Changes
 - The :class:`Cleaner` now exposes a parameter to convert numerical values to float32. :pr:`1440` by
   :user:`Riccardo Cappuzzo<rcap107>`.
 
+- The :class:`TableReport` now shows if columns are sorted. :pr:`1512` by :user:`Dea María Léon<DeaMariaLeon>`.
+
+- The ``tabular_learner`` function has been deprecated in favor of :func:`tabular_pipeline` to honor
+  its scikit-learn pipeline cultural heritage, and remove the ambiguity with the data
+  ops Learner. :pr:`1493` by :user:`Vincent Maladiere <Vincent-Maladiere>`.
+
+- A new parameter ``float_precision`` has been added to the global config to control the number of significant digits
+  displayed for floating-point values in reports. :pr:`1470` by :user:`George S <georgescutelnicu>`.
 
 Bugfixes
 --------
diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst
index 88deeab..8005198 100644
--- a/CONTRIBUTING.rst
+++ b/CONTRIBUTING.rst
@@ -364,10 +364,20 @@ specific examples, you can use the following command with a regex pattern:
 
 .. code:: bash
 
-    make html EXAMPLES_PATTERN=your_regex_goes_here make html
+    make html EXAMPLES_PATTERN=your_regex_goes_here
 
 This is especially helpful when you're only modifying or checking a few examples.
 
+It is also possible to build the documentation without running the examples
+by using the following command:
+
+.. code:: bash
+
+    make html-noplot
+
+This command generates the documentation without re-executing the examples, which
+can take a long time. This is useful if you are only modifying the
+documentation itself, such as fixing typos or improving explanations.
 
 Editing the API reference documentation
 ---------------------------------------
diff --git a/doc/api_reference.py b/doc/api_reference.py
index dc7e827..8f475ac 100644
--- a/doc/api_reference.py
+++ b/doc/api_reference.py
@@ -61,12 +61,13 @@ API_REFERENCE = {
                     "pipelines, see the :ref:`skrub expressions <expressions_ref>`."
                 ),
                 "autosummary": [
+                    "tabular_pipeline",
                     "tabular_learner",
                     "TableVectorizer",
-                    "Cleaner",
                     "SelectCols",
                     "DropCols",
-                    "DropUninformative",
+                    "ApplyToCols",
+                    "ApplyToFrame",
                 ],
             }
         ],
@@ -117,6 +118,8 @@ API_REFERENCE = {
                 "description": None,
                 "autosummary": [
                     "deduplicate",
+                    "Cleaner",
+                    "DropUninformative",
                 ],
             },
         ],
@@ -267,6 +270,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,
@@ -293,6 +311,7 @@ API_REFERENCE = {
                     "datasets.fetch_videogame_sales",
                     "datasets.get_data_dir",
                     "datasets.make_deduplication_data",
+                    "datasets.toy_orders",
                 ],
             }
         ],
diff --git a/doc/end_to_end_pipeline.rst b/doc/end_to_end_pipeline.rst
index 60f4d28..76e4767 100644
--- a/doc/end_to_end_pipeline.rst
+++ b/doc/end_to_end_pipeline.rst
@@ -6,7 +6,7 @@ End-to-end predictive models
 
 .. currentmodule:: skrub
 
-.. _tabular_learner:
+.. _tabular_pipeline:
 
 Create baseline predictive models on heterogeneous dataset
 ----------------------------------------------------------
@@ -17,7 +17,7 @@ regarding the preprocessing steps that are are required ahead of the learner.
 Programmatically defining these steps is the part that requires the most expertise and
 that is cumbersome to write.
 
-The function :func:`tabular_learner` provides a factory function that, given a
+The function :func:`tabular_pipeline` provides a factory function that, given a
 scikit-learn estimator, returns a pipeline combining this estimator with the
 appropriate preprocessing steps. These steps correspond to a :class:`TableVectorizer`
 that handles heterogeneous data and, depending on the capabilities
@@ -27,7 +27,7 @@ In the next section, we provide more details regarding the :class:`TableVectoriz
 (:ref:`table_vectorizer`). The parameters of the :class:`TableVectorizer` are chosen
 based on the type of the final estimator.
 
-.. list-table:: Parameter values choice of :class:`TableVectorizer` when using the :func:`tabular_learner` function
+.. list-table:: Parameter values choice of :class:`TableVectorizer` when using the :func:`tabular_pipeline` function
    :header-rows: 1
 
    * -
@@ -121,7 +121,7 @@ Examples
 --------
 
 The following examples provide an in-depth look at how to use the
-:class:`~skrub.TableVectorizer` class and the :func:`~skrub.tabular_learner`
+:class:`~skrub.TableVectorizer` class and the :func:`~skrub.tabular_pipeline`
 function.
 
 - :ref:`sphx_glr_auto_examples_01_encodings.py`
diff --git a/doc/install.rst b/doc/install.rst
index c52de5c..3f02d70 100644
--- a/doc/install.rst
+++ b/doc/install.rst
@@ -203,6 +203,7 @@ To ensure your environment is correctly set up, run the test suite:
 Testing should take about 5 minutes.
 
 If you see some warnings like:
+
 .. code:: sh
 
   UserWarning: Only pandas and polars DataFrames are supported, but input is a Numpy array. Please convert Numpy arrays to DataFrames before passing them to skrub transformers. Converting to pandas DataFrame with columns ['0', '1', …].
diff --git a/examples/00_getting_started.py b/examples/00_getting_started.py
index 487a707..a79c4e9 100644
--- a/examples/00_getting_started.py
+++ b/examples/00_getting_started.py
@@ -91,22 +91,22 @@ employees_df
 # --------------------------------------------------------------
 #
 # The goal of ``skrub`` is to ease tabular data preparation for machine learning.
-# The :func:`~skrub.tabular_learner` function provides an easy way to build a simple
+# The :func:`~skrub.tabular_pipeline` function provides an easy way to build a simple
 # but reliable machine-learning model, working well on most tabular data.
 
 
 # %%
 from sklearn.model_selection import cross_validate
 
-from skrub import tabular_learner
+from skrub import tabular_pipeline
 
-model = tabular_learner("regressor")
+model = tabular_pipeline("regressor")
 results = cross_validate(model, employees_df, salaries)
 results["test_score"]
 
 # %%
 # To handle rich tabular data and feed it to a machine-learning model, the
-# pipeline returned by :func:`~skrub.tabular_learner` preprocesses and encodes
+# pipeline returned by :func:`~skrub.tabular_pipeline` preprocesses and encodes
 # strings, categories and dates using the :class:`~skrub.TableVectorizer`.
 # See its documentation or :ref:`sphx_glr_auto_examples_01_encodings.py` for
 # more details. An overview of the chosen defaults is available in
diff --git a/examples/01_encodings.py b/examples/01_encodings.py
index c30fe71..e908369 100644
--- a/examples/01_encodings.py
+++ b/examples/01_encodings.py
@@ -71,14 +71,14 @@ employees
 
 from sklearn.model_selection import cross_validate
 
-from skrub import tabular_learner
+from skrub import tabular_pipeline
 
-model = tabular_learner("regressor")
+model = tabular_pipeline("regressor")
 results = cross_validate(model, employees, salaries)
 results["test_score"]
 
 # %%
-# The estimator returned by :obj:`tabular_learner` combines 2 steps:
+# The estimator returned by :obj:`tabular_pipeline` combines 2 steps:
 #
 # - a |TableVectorizer| to preprocess the dataframe and vectorize the features
 # - a supervised learner (by default a |HGBR|)
@@ -262,7 +262,7 @@ print(f"mean fit time: {np.mean(results['fit_time']):.3f} seconds")
 # learner is built on top of decision trees, which is the case for the |HGBR|.
 #
 # The resulting pipeline is identical to the one produced by default by
-# :obj:`tabular_learner`.
+# :obj:`tabular_pipeline`.
 
 from skrub import MinHashEncoder, ToCategorical
 
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..cf1a8fc
--- /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
+
+model = 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/examples/expressions/10_expressions_intro.py b/examples/expressions/10_expressions_intro.py
index 364a6c6..991087a 100644
--- a/examples/expressions/10_expressions_intro.py
+++ b/examples/expressions/10_expressions_intro.py
@@ -1,50 +1,70 @@
 """
+.. currentmodule:: skrub
+
 .. _example_expressions_intro:
 
+Building a predictive model by combining multiple tables with the skrub DataOps
+==============================================================================
+
+This example introduces the skrub DataOps, that builds complex data processing pipelines
+handling multiple tables, with hyperparameter tuning and model selection.
+
+DataOps form implicitly a "plan", which records all the operations performed on
+the data; the DataOps plan can be exported as a ``Learner``, a standalone object
+that can be saved on disk, loaded in a new environment, and used to make predictions
+on new data.
+
+Here we show the basics of the skrub DataOps in a two-table scenario: how to create
+DataOps, how to use them to leverage dataframe operations, how to combine them in
+a full DataOps plan, how to do simple hyperparameter tuning, and finally how to
+export the plan as a ``Learner``.
 
-Building complex tabular pipelines
-==================================
 
-In this example, we show a simple pipeline handling a dataset with 2 tables,
-which would be difficult to implement, validate, and deploy correctly without
-skrub.
 """
 
 # %%
 # The credit fraud dataset
 # ------------------------
 #
-# This dataset comes from an e-commerce website. We have a set of "baskets" (
-# orders that have been placed with the website). The task is to detect which
-# orders were fraudulent (the customer never made the payment).
+# This dataset originates from an e-commerce website and is structured into two tables:
+#
+# - The "baskets" table contains order IDs, each representing a list of purchased
+#   products.
+#   For a subset of these orders (the training set), a flag indicates whether
+#   the order was fraudulent.
+#   This fraud flag is the target variable we aim to predict during inference.
+# - The "products" table provides the detailed contents of all baskets, including
+#   those without a known fraud label.
 #
 # The ``baskets`` table contains a basket ID and a flag indicating if the order
 # was fraudulent or not.
+# We start by loading the ``baskets`` table, and exploring it with the ``TableReport``.
 
 # %%
-import skrub
 import skrub.datasets
+from skrub import TableReport
 
-dataset = skrub.datasets.fetch_credit_fraud()
-skrub.TableReport(dataset.baskets)
+dataset = skrub.datasets.fetch_credit_fraud()  # load labeled data
+TableReport(dataset.baskets)
 
 # %%
-# Each basket contains one or more products. Each row in the ``products`` table
-# corresponds to a type of product present in a basket. Products can
-# be associated with the corresponding basket through the ``"basket_ID"``
-# column.
+# We then load the ``products`` table, which contains one row per purchased product.
 
 # %%
-skrub.TableReport(dataset.products)
+TableReport(dataset.products)
 
 # %%
-# A data-processing challenge
+# Each basket contains at least one product, and products can
+# be associated with the corresponding basket through the ``"basket_ID"``
+# column.
+#
+# A design problem: how to combine tables while avoiding leakage?
 # ----------------------------
 #
 # We want to fit a ``HistGradientBoostingClassifier`` to predict the fraud
-# flag (or ``y``). We build a design matrix with one row per basket (and thus per
-# fraud flag). Our ``baskets`` (or ``X``) table only contains IDs. We enrich it by
-# adding features from the ``products`` table.
+# flag (or ``y``). To do so, we build a design matrix where each row corresponds
+# to a basket, and we want to add features from the ``products`` table to
+# each basket.
 #
 # The general structure of the pipeline looks like this:
 #
@@ -54,10 +74,10 @@ skrub.TableReport(dataset.products)
 #
 # First, as the ``products`` table contains strings and categories (such as
 # ``"SAMSUNG"``), we vectorize those entries to extract numeric
-# features. This is easily done with skrub's ``TableVectorizer``. Then, as each
-# basket can contain several products, all the product lines corresponding to a
-# basket are aggregated into a single feature vector that can be
-# attached to the basket.
+# features. This is easily done with skrub's ``TableVectorizer``. Then, since each
+# basket can contain several products, we want to aggregate all the lines in
+# ``products`` that correspond to a single basket into a single vector that can
+# then be attached to the basket.
 #
 # The difficulty is that the vectorized ``products`` should be aggregated before joining
 # to ``baskets``, and, in order to compute a meaningful aggregation, must
@@ -74,8 +94,7 @@ skrub.TableReport(dataset.products)
 # However, because it is dissociated from the main estimator which handles
 # ``X`` (the baskets), we have to manage this transformer ourselves. We lose
 # the scikit-learn machinery for grouping all transformation steps,
-# storing fitted estimators, splitting the input data and cross-validation, and
-# hyper-parameter tuning.
+# storing fitted estimators, cross-validating the data, and tuning hyper-parameters.
 #
 # Moreover, we might need some Pandas code to perform the aggregation and join.
 # Again, as this transformation is not in a scikit-learn estimator, it is error-prone.
@@ -87,17 +106,29 @@ skrub.TableReport(dataset.products)
 # more flexible pipelines.
 
 # %%
-# A solution with skrub
-# ---------------------
-#
-# In a skrub pipeline, we do not provide an explicit list of
-# transformation steps. Rather, we manipulate skrub objects representing
-# intermediate results. The pipeline is built implicitly as we perform
+# DataOps make DataOps plans
+# --------------------------
+# In a skrub DataOps plan, we do not have an explicit, sequential list of
+# transformation steps. Instead, we perform "Data Operations" (or "DataOps"):
+# operations that act on variables and wrap user operations to keep track
+# of their parameters.
+#
+# User operations could be dataframe operations (selection, merge, group by, etc.),
+# scikit-learn estimators (such as a RandomForest with its hyperparameters),
+# or arbitrary code (for loading data, converting values, etc.).
+#
+# As we perform operations on skrub variables, the plan records each DataOp
+# and its parameters. This record can later be synthesized into a standalone object
+# called a "learner", which can replay these operations on unseen data, ensuring
+# that the same operations and parameters are used.
+#
+# In a DataOps plan, we manipulate skrub objects representing
+# intermediate results. The plan is built implicitly as we perform
 # operations (such as applying operators or calling functions) on those
 # objects.
 
 # %%
-# We start by creating skrub variables, which are the inputs to our pipeline.
+# We start by creating skrub variables, which are the inputs to our plan.
 # In our example, we create three variables: "products", "baskets", and "fraud flags":
 
 # %%
@@ -108,24 +139,27 @@ baskets = full_baskets[["ID"]].skb.mark_as_X()
 fraud_flags = full_baskets["fraud_flag"].skb.mark_as_y()
 
 # %%
-# They are given a name and an (optional) initial
-# value, used to show previews of the pipeline's output, detect errors
+# Variables are given a name and an (optional) initial
+# value, which is used to show previews of the result of each DataOp, detect errors
 # early, and provide data for cross-validation and hyperparameter search.
 #
-# We then build the pipeline by applying transformations to those inputs.
+# Then, the plan is built by applying DataOps to those variables, that
+# is, by performing user operations that have been wrapped in a DataOp.
 #
 # Above, ``mark_as_X()`` and ``mark_as_y()`` indicate that the baskets and
 # flags are respectively our design matrix and target variables, that
 # should be split into training and testing sets for cross-validation. Here,
-# they are direct inputs to the pipeline but any
+# they are direct inputs to the plan, but any
 # intermediate result could be marked as X or y.
 #
-# Because our pipeline expects dataframes for products, baskets and fraud
-# flags, we manipulate those objects as we would manipulate dataframes.
-# All attribute accesses are transparently forwarded to the actual input
-# dataframes when we run the pipeline.
+# By setting products, baskets and fraud_flags as skrub variables, we can manipulate
+# those objects as if they were dataframes, while keeping track of all the operations
+# that are performed on them. Additionally, skrub variables and DataOps provide
+# their own
+# set of methods, which are
+# accessible through the ``skb`` attribute of any skrub variable and DataOp.
 #
-# For instance, we filter products to keep only those that match one of the
+# For instance, we can filter products to keep only those that match one of the
 # baskets in the ``baskets`` table, and then add a column containing the total
 # amount for each kind of product in a basket:
 
@@ -140,33 +174,34 @@ products_with_total
 # We see previews of the output of intermediate results. For
 # example, the added ``"total_price"`` column is in the output above.
 # The "Show graph" dropdown at the top allows us to check the
-# structure of the pipeline and all the steps it contains.
+# structure of the DataOps plan and all the DataOps it contains.
 #
 # .. note::
 #
-#    We recommend to assign each new skrub expression to a new variable name,
+#    We recommend to assign each new skrub DataOp to a new variable name,
 #    as is done above. For example ``kept_products = products[...]`` instead of
 #    reusing the name ``products = products[...]``. This makes it easy to
-#    backtrack to any step of the pipeline and change the subsequent steps, and
+#    backtrack to any step of the plan and change the subsequent steps, and
 #    can avoid ending up in a confusing state in jupyter notebooks when the
 #    same cell might be re-executed several times.
 #
-# With skrub, we do not need to specify a grid of hyperparameters separately
-# from the pipeline. Instead, we replace a parameter's value with a skrub
-# "choice" which indicates the range of values we consider during
+# A major advantage of the skrub DataOps plan is that it allows to specify a
+# grid of hyperparameter choices where the parameter is defined, improving code
+# readability and maintainability.
+# We do so by replacing a parameter's value with a skrub
+# "choice", which indicates the range of values we consider during
 # hyperparameter selection.
 #
 # Skrub choices can be nested arbitrarily. They are not restricted to
 # parameters of a scikit-learn estimator, but can be anything: choosing
 # between different estimators, arguments to function calls, whole sections of
-# the pipeline etc.
+# the plan etc.
 #
 # In-depth information about choices and hyperparameter/model selection is
-# provided in the :ref:`Tuning Pipelines example <example_tuning_pipelines>`.
+# provided in the :ref:`Tuning Data Plans example <example_tuning_pipelines>`.
 #
-# We build a skrub ``TableVectorizer`` with different choices of:
-# the type of encoder for high-cardinality categorical or string columns, and
-# the number of components it uses.
+# Here, we build a skrub ``TableVectorizer`` with choices for the high-cardinality
+# encoder, and the number of components it uses.
 
 # %%
 n = skrub.choose_int(5, 15, name="n_components")
@@ -191,8 +226,8 @@ vectorized_products = products_with_total.skb.apply(
 
 # %%
 # Having access to the underlying dataframe's API, we can perform the
-# data-wrangling we need. Those transformations are being implicitly added
-# as steps in our machine-learning pipeline.
+# data-wrangling we need. Those transformations are being implicitly recorded
+# as DataOps in our plan.
 
 # %%
 aggregated_products = vectorized_products.groupby("basket_ID").agg("mean").reset_index()
@@ -201,8 +236,8 @@ augmented_baskets = baskets.merge(
 ).drop(columns=["ID", "basket_ID"])
 
 # %%
-# We can actually ask for a full report of the pipeline and inspect the
-# results at every step::
+# We can actually ask for a full report of the plan and inspect the
+# results of each DataOp::
 #
 #     predictions.skb.full_report()
 #
@@ -222,9 +257,9 @@ predictions = augmented_baskets.skb.apply(hgb, y=fraud_flags)
 predictions
 
 # %%
-# And our pipeline is complete!
+# And our DataOps plan is complete!
 #
-# From the choices we inserted at different locations in our pipeline, skrub
+# From the choices we inserted at different locations in our plan, skrub
 # can build a grid of hyperparameters and run the hyperparameter search for us,
 # backed by scikit-learn's ``GridSearchCV`` or ``RandomizedSearchCV``.
 
@@ -263,23 +298,27 @@ search.plot_results()
 # It seems here that using the LSA as an encoder brings better test scores,
 # but at the expense of training and scoring time.
 #
-# Serializing
-# -----------
-# We would usually save this model in a binary file, but to avoid accessing the
-# filesystem with this example notebook, we serialize the model in memory instead.
+# From the DataOps plan to the learner
+# ------------------------------------------
+# The learner is a standalone object that can replay all the DataOps recorded in
+# the plan, and can be used to make predictions on new, unseen data. The
+# learner can be saved and loaded, allowing us to use it later without having to
+# rebuild the plan.
+# We would usually save the learner in a binary file, but to avoid accessing the
+# filesystem with this example notebook, we serialize the learner in memory instead.
 import pickle
 
-saved_model = pickle.dumps(search.best_pipeline_)
+saved_model = pickle.dumps(search.best_pipeline_)  # This gives us our learner
 
 # %%
-# Let's say we got some new data, and we want to use the model we just saved
+# Let's say we got some new data, and we want to use the learner we just saved
 # to make predictions on them:
 new_data = skrub.datasets.fetch_credit_fraud(split="test")
 new_baskets = new_data.baskets[["ID"]]
 new_products = new_data.products
 
 # %%
-# Our pipeline expects the same variable names as the training pipeline, which is why
+# Our learner expects the same variable names as the training plan, which is why
 # we pass a dictionary that contains new dataframes and the same variable:
 loaded_model = pickle.loads(saved_model)
 loaded_model.predict({"baskets": new_baskets, "products": new_products})
@@ -289,5 +328,9 @@ loaded_model.predict({"baskets": new_baskets, "products": new_products})
 # ----------
 #
 # If you are curious to know more on how to build your own complex, multi-table
-# pipelines with easy hyperparameter tuning, please see the next examples for an
-# in-depth tutorial.
+# plans with easy hyperparameter tuning and transforming them into reusable
+# learners, please see the next examples for an in-depth tutorial.
+#
+# Indeed, the following examples will explain how to tune hyperparameters in detail (see
+# :ref:`example_tuning_pipelines`), and how to speed up development by subsampling
+# preview data (see :ref:`example_subsampling`).
diff --git a/examples/expressions/11_choices.py b/examples/expressions/11_choices.py
index 075f722..a4e686a 100644
--- a/examples/expressions/11_choices.py
+++ b/examples/expressions/11_choices.py
@@ -2,12 +2,10 @@
 .. currentmodule:: skrub
 
 .. _example_tuning_pipelines:
-
-
-Tuning pipelines
+Tuning DataOps
 ================
 
-Our machine-learning pipeline typically contains some values or choices which
+A machine-learning pipeline typically contains some values or choices which
 may influence its prediction performance, such as hyperparameters (e.g. the
 regularization parameter ``alpha`` of a ``RidgeClassifier``, the
 ``learning_rate`` of a ``HistGradientBoostingClassifier``), which estimator to
@@ -18,7 +16,7 @@ or not).
 We want to tune those choices by trying several options and keeping those that
 give the best performance on a validation set.
 
-Skrub :ref:`expressions <skrub_pipeline>` provide a convenient way to specify
+Skrub :ref:`DataOps <skrub_pipeline>` provide a convenient way to specify
 the range of possible values, by inserting it directly in place of the actual
 value. For example we can write:
 
@@ -29,7 +27,7 @@ instead of:
 ``RidgeClassifier(alpha=1.0)``.
 
 Skrub then inspects
-our pipeline to discover all the places where we used objects like
+our DataOps plan to discover all the places where we used objects like
 ``skrub.choose_from()`` and builds a grid of hyperparameters for us.
 """
 
@@ -55,7 +53,8 @@ texts = data[["text"]]
 labels = data["is_toxic"]
 
 # %%
-# We mark the ``texts`` column as the input and the ``labels`` column as the target.
+# We mark the ``texts`` column as the input variable and the ``labels`` column as
+# the target variable.
 # See `the previous example <10_expressions.html>`_ for a more detailed explanation
 # of ``skrub.X`` and ``skrub.y``.
 # We then encode the text with a ``MinHashEncoder`` and fit a
@@ -84,7 +83,7 @@ pred.skb.cross_validate(n_jobs=4)["test_score"]
 # ``RandomizedSearchCV``, we need to specify a grid of hyperparameters separately
 # from the estimator, with something similar to
 # ``GridSearchCV(my_pipeline, param_grid={"encoder__n_components: [5, 10, 20]"})``.
-# Instead, with skrub we can use
+# Instead, within a skrub DataOps plan we can use
 # ``skrub.choose_from(...)`` directly where the actual value
 # would normally go. Skrub then takes care of constructing the
 # ``GridSearchCV``'s parameter grid for us.
@@ -98,17 +97,18 @@ pred.skb.cross_validate(n_jobs=4)["test_score"]
 #   transformation step optional such as
 #   ``X.skb.apply(skrub.optional(StandardScaler()))``
 #
-# Choices can be given a name which is used to display hyperparameter search
-# results and plots or to override their outcome. The name is optional.
+# Choices can be given an optional name which is used to display hyperparameter
+# search results and plots, or to override their outcome (ADD A REFERENCE HERE).
 #
 # Note that :func:`skrub.choose_float()` and :func:`skrub.choose_int()` can be given a
-# ``log`` argument to sample in log scale.
+# ``log`` argument to sample in log scale, and that it is possible to specify the
+# number of steps with the ``n_steps`` argument.
 
 # %%
 X, y = skrub.X(texts), skrub.y(labels)
 
 encoder = skrub.MinHashEncoder(
-    n_components=skrub.choose_int(5, 15, name="N components")
+    n_components=skrub.choose_int(5, 15, n_steps=5, name="N components")
 )
 classifier = HistGradientBoostingClassifier(
     learning_rate=skrub.choose_float(0.01, 0.9, log=True, name="lr")
@@ -116,11 +116,11 @@ classifier = HistGradientBoostingClassifier(
 pred = X.skb.apply(encoder).skb.apply(classifier, y=y)
 
 # %%
-# We can then obtain a pipeline that performs the hyperparameter search with
+# From here, the ``pred`` DataOp can be used to perform hyperparameter search with
 # ``.skb.get_grid_search()`` or ``.skb.get_randomized_search()``. They accept
-# the same arguments as their scikit-learn counterparts (e.g. ``scoring`` and
+# the same arguments as their scikit-learn counterparts (e.g. ``scoring``, ``cv``,
 # ``n_jobs``). Also, like ``.skb.get_pipeline()``, they accept a ``fitted``
-# argument and if it is ``True`` the search is fitted on the data we provided
+# argument: if``fitted=True``, the search is fitted on the data we provided
 # when initializing our pipeline's variables.
 
 search = pred.skb.get_randomized_search(n_iter=8, n_jobs=4, random_state=1, fitted=True)
@@ -154,7 +154,7 @@ search.plot_results()
 # example in previews or to get a quick first result before spending the
 # computation time to run the search. When we use :meth:`.skb.get_pipeline()
 # <Expr.skb.get_pipeline>`, we get a pipeline that does not perform any tuning
-# and uses those default values. That default pipeline is the one used for
+# and uses those default values. This default pipeline is used for
 # :meth:`.skb.eval() <Expr.skb.eval>`.
 #
 # We can control what should be the default value for each choice. For
@@ -248,7 +248,7 @@ search = pred.skb.get_randomized_search(
 search.plot_results()
 
 # %%
-# Now that we have a more complex pipeline, we can draw more conclusions from the
+# Now that we have a more complex plan, we can draw more conclusions from the
 # parallel coordinate plot. For example, we can see that the
 # ``HistGradientBoostingClassifier``
 # performs better than the ``RidgeClassifier`` in most cases, that the ``StringEncoder``
@@ -258,13 +258,13 @@ search.plot_results()
 # %%
 # Concluding, we have seen how to use skrub's ``choose_from`` objects to tune
 # hyperparameters, choose optional configurations, and nest choices. We then
-# looked at how the different choices affect the pipeline and the prediction
+# looked at how the different choices affect the plan and the prediction
 # scores.
 #
 # There is more to say about skrub choices than what is covered in this
 # example. In particular, choices are not limited to choosing estimators and
-# their hyperparameters: they can be used anywhere an expression can be used,
+# their hyperparameters: they can be used anywhere DataOps are used,
 # such as the argument of a :func:`deferred` function, or the argument of
-# another expression's method or operator. Finally, choices can be
+# other DataOps' method or operator. Finally, choices can be
 # inter-dependent. Please find more information in the :ref:`user guide
 # <skrub_pipeline_validation>`.
diff --git a/examples/expressions/12_subsampling.py b/examples/expressions/12_subsampling.py
index 3e22d45..b6c1761 100644
--- a/examples/expressions/12_subsampling.py
+++ b/examples/expressions/12_subsampling.py
@@ -2,13 +2,12 @@
 .. currentmodule:: skrub
 
 .. _example_subsampling:
-
 Subsampling for faster development
 ==================================
 
-Here we show how to use :meth:`.skb.subsample()
-<Expr.skb.subsample>` to speed-up interactive creation of skrub
-expressions by subsampling the data when computing preview results.
+Here we show how to use :meth:`.skb.subsample() <Expr.skb.subsample>` to speed-up
+interactive construction of a skrub DataOps plan by computing previews on a subsampled
+version of the original data.
 """
 
 # %%
@@ -22,12 +21,12 @@ full_data = skrub.var("data", dataset)
 full_data
 
 # %%
-# We are working with a dataset of over 9K rows. As we build up our pipeline,
+# We are working with a dataset of over 9K rows. As we build up our plan,
 # we see previews of the intermediate results so we can check that it behaves
 # as we expect. However, if some estimators are slow, fitting them and
 # computing results on the whole data can slow us down.
 #
-# Lightweight construction of the pipeline on a subsample
+# Lightweight construction of the DataOps plan on a subsample
 # -------------------------------------------------------------------------------------
 #
 # We can tell skrub to subsample the data when computing the previews, with
@@ -38,19 +37,19 @@ data = full_data.skb.subsample(n=100)
 data
 
 # %%
-# The rest of the pipeline will now use only 100 points for its previews.
+# The rest of the plan will now use only 100 points for its previews.
 #
 # .. topic:: Subsampling only applies to previews by default
 #
 #    By default subsampling is applied *only for previews*: the results
-#    shown when we display the expression, and the output of calling
+#    shown when we display the plan, and the output of calling
 #    :meth:`.skb.preview() <Expr.skb.preview>`. For other methods such as
-#    :meth:`.skb.get_pipeline() <Expr.skb.get_pipeline>` or
+#    :meth:`.skb.get_learner() <Expr.skb.get_learner>` or
 #    :meth:`.skb.cross_validate() <Expr.skb.cross_validate>`, *no subsampling is
 #    done by default*. We can explicitly ask for it with ``keep_subsampling=True``
 #    as we will see below.
 #
-# To continue our pipeline we now define X and y:
+# To continue building our plan, we now define X and y:
 
 # %%
 employees = data.drop(
@@ -74,14 +73,14 @@ predictions = employees.skb.apply(skrub.TableVectorizer()).skb.apply(
 #
 # All the lines above run very fast, including fitting the predictor above.
 #
-# When we display our ``predictions`` expression, we see that the preview is
+# When we display our ``predictions`` DataOp, we see that the preview is
 # computed on a subsample: the result column has only 100 entries.
 
 # %%
 predictions
 
 # %%
-# We can also turn on subsampling for other methods of the expression, such as
+# We can also turn on subsampling for other DataOps methods, such as
 # :meth:`.skb.cross_validate() <Expr.skb.cross_validate>`. Here we run the
 # cross-validation on the small subsample of 100 rows we configured. With such
 # a small subsample the scores will be very low but this might help us quickly
@@ -92,7 +91,7 @@ predictions.skb.cross_validate(keep_subsampling=True)
 
 # %%
 #
-# Evaluating the pipeline on the full data
+# Evaluating the DataOps plan on the full data
 # --------------------------------------------------------
 # By default, when we do not explicitly ask for ``keep_subsampling=True``, no
 # subsampling takes place.
diff --git a/skrub/__init__.py b/skrub/__init__.py
index 9bbc0b5..67c3420 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
@@ -39,11 +41,10 @@ from ._select_cols import Drop, DropCols, SelectCols
 from ._similarity_encoder import SimilarityEncoder
 from ._string_encoder import StringEncoder
 from ._table_vectorizer import Cleaner, TableVectorizer
-from ._tabular_learner import tabular_learner
+from ._tabular_pipeline import tabular_learner, tabular_pipeline
 from ._text_encoder import TextEncoder
 from ._to_categorical import ToCategorical
 from ._to_datetime import ToDatetime, to_datetime
-from .datasets import toy_orders
 
 with open(_Path(__file__).parent / "VERSION.txt") as _fh:
     __version__ = _fh.read().strip()
@@ -62,6 +63,7 @@ __all__ = [
     "TableReport",
     "patch_display",
     "unpatch_display",
+    "tabular_pipeline",
     "tabular_learner",
     "DatetimeEncoder",
     "ToDatetime",
@@ -94,8 +96,9 @@ __all__ = [
     "TextEncoder",
     "StringEncoder",
     "column_associations",
-    "toy_orders",
     "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/_config.py b/skrub/_config.py
index c5dc1af..ef1241c 100644
--- a/skrub/_config.py
+++ b/skrub/_config.py
@@ -14,6 +14,8 @@ _global_config = {
     "max_association_columns": int(os.environ.get("SKB_MAX_ASSOCIATION_COLUMNS", 30)),
     "subsampling_seed": int(os.environ.get("SKB_SUBSAMPLING_SEED", 0)),
     "enable_subsampling": os.environ.get("SKB_ENABLE_SUBSAMPLING", "default"),
+    "float_precision": int(os.environ.get("SKB_FLOAT_PRECISION", 3)),
+    "cardinality_threshold": int(os.environ.get("SKB_CARDINALITY_THRESHOLD", 40)),
 }
 _threadlocal = threading.local()
 
@@ -70,6 +72,8 @@ def set_config(
     max_association_columns=None,
     subsampling_seed=None,
     enable_subsampling=None,
+    float_precision=None,
+    cardinality_threshold=None,
 ):
     """Set global skrub configuration.
 
@@ -131,6 +135,21 @@ def set_config(
         This configuration can also be set with the ``SKB_ENABLE_SUBSAMPLING``
         environment variable.
 
+    float_precision : int, default=3
+        Control the number of significant digits shown when formatting floats.
+        Applies overall precision rather than fixed decimal places. Default is 3.
+
+        This configuration can also be set with the ``SKB_FLOAT_PRECISION``
+        environment variable.
+
+    cardinality_threshold : int, default=40
+        Set the ``cardinality_threshold`` argument of :class:`~skrub.TableVectorizer`.
+        Control the threshold value used to warn user if they have
+        high cardinality columns in there dataset.
+
+        This configuration can also be set with the ``SKB_CARDINALITY_THRESHOLD``
+        environment variable.
+
     See Also
     --------
     get_config : Retrieve current values for global configuration.
@@ -184,6 +203,23 @@ def set_config(
             )
         local_config["enable_subsampling"] = enable_subsampling
 
+    if float_precision is not None:
+        if not isinstance(float_precision, numbers.Integral) or float_precision <= 0:
+            raise ValueError(
+                f"'float_precision' must be a positive integer, got {float_precision!r}"
+            )
+        local_config["float_precision"] = float_precision
+
+    if cardinality_threshold is not None:
+        if (
+            not isinstance(cardinality_threshold, numbers.Integral)
+            or cardinality_threshold < 0
+        ):
+            raise ValueError(
+                "'cardinality_threshold' must be a positive"
+                f"integer, got {cardinality_threshold!r}"
+            )
+
     _apply_external_patches(local_config)
 
 
@@ -196,6 +232,8 @@ def config_context(
     max_association_columns=None,
     subsampling_seed=None,
     enable_subsampling=None,
+    float_precision=None,
+    cardinality_threshold=None,
 ):
     """Context manager for global skrub configuration.
 
@@ -257,6 +295,21 @@ def config_context(
         This configuration can also be set with the ``SKB_ENABLE_SUBSAMPLING``
         environment variable.
 
+    float_precision : int, default=3
+        Control the number of significant digits shown when formatting floats.
+        Applies overall precision rather than fixed decimal places. Default is 3.
+
+        This configuration can also be set with the ``SKB_FLOAT_PRECISION``
+        environment variable.
+
+    cardinality_threshold : int, default=40
+        Set the ``cardinality_threshold`` argument of :class:`~skrub.TableVectorizer`.
+        Control the threshold value used to warn user if they have
+        high cardinality columns in there dataset.
+
+        This configuration can also be set with the ``SKB_CARDINALITY_THRESHOLD``
+        environment variable.
+
     Yields
     ------
     None.
@@ -280,6 +333,8 @@ def config_context(
         max_association_columns=max_association_columns,
         subsampling_seed=subsampling_seed,
         enable_subsampling=enable_subsampling,
+        float_precision=float_precision,
+        cardinality_threshold=cardinality_threshold,
     )
 
     try:
diff --git a/skrub/_dataframe/_common.py b/skrub/_dataframe/_common.py
index 9e527b3..c362135 100644
--- a/skrub/_dataframe/_common.py
+++ b/skrub/_dataframe/_common.py
@@ -107,6 +107,7 @@ __all__ = [
     "with_columns",
     "abs",
     "total_seconds",
+    "is_sorted",
 ]
 
 pandas_version = parse_version(parse_version(pd.__version__).base_version)
@@ -1387,3 +1388,19 @@ def _total_seconds_pandas(col):
 @total_seconds.specialize("polars", argument_type="Column")
 def _total_seconds_polars(col):
     return col.dt.total_microseconds().cast(float) * 1e-6
+
+
+@dispatch
+def is_sorted(col):
+    """Check if a column is sorted."""
+    raise _raise(col, kind="Series")
+
+
+@is_sorted.specialize("pandas", argument_type="Column")
+def _is_sorted_pandas(col):
+    return col.is_monotonic_increasing or col.is_monotonic_decreasing
+
+
+@is_sorted.specialize("polars", argument_type="Column")
+def _is_sorted_polars(col):
+    return col.is_sorted()
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..460b14b 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
@@ -210,7 +210,7 @@ class SkrubPipeline(_CloudPickleExpr, BaseEstimator):
         >>> import skrub
         >>> from skrub import selectors as s
 
-        >>> orders = skrub.toy_orders()
+        >>> orders = skrub.datasets.toy_orders()
         >>> X, y = skrub.X(), skrub.y()
         >>> pred = (
         ...     X.skb.apply(skrub.StringEncoder(n_components=2), cols=["product"])
@@ -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
@@ -328,7 +328,7 @@ class SkrubPipeline(_CloudPickleExpr, BaseEstimator):
         >>> from sklearn.dummy import DummyClassifier
         >>> import skrub
 
-        >>> orders = skrub.toy_orders()
+        >>> orders = skrub.datasets.toy_orders()
         >>> X, y = skrub.X(), skrub.y()
         >>> pred = (
         ...     X.skb.apply(
@@ -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..7b1be46 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"]:
@@ -974,7 +974,7 @@ def X(value=NULL):
     Examples
     --------
     >>> import skrub
-    >>> df = skrub.toy_orders().orders_
+    >>> df = skrub.datasets.toy_orders().orders_
     >>> X = skrub.X(df)
     >>> X
     <Var 'X'>
@@ -1036,7 +1036,7 @@ def y(value=NULL):
     Examples
     --------
     >>> import skrub
-    >>> col = skrub.toy_orders().delayed
+    >>> col = skrub.datasets.toy_orders().delayed
     >>> y = skrub.y(col)
     >>> y
     <Var 'y'>
@@ -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..95e9543 100644
--- a/skrub/_expressions/_skrub_namespace.py
+++ b/skrub/_expressions/_skrub_namespace.py
@@ -185,8 +185,8 @@ class SkrubNamespace:
         Examples
         --------
         >>> import skrub
-
-        >>> x = skrub.X(skrub.toy_orders().X)
+        >>> data = skrub.datasets.toy_orders()
+        >>> x = skrub.X(data.X)
         >>> x
         <Var 'X'>
         Result:
@@ -253,7 +253,7 @@ class SkrubNamespace:
         For supervised estimators, pass the targets as the argument for ``y``:
 
         >>> from sklearn.dummy import DummyClassifier
-        >>> y = skrub.y(skrub.toy_orders().y)
+        >>> y = skrub.y(data.y)
         >>> y
         <Var 'y'>
         Result:
@@ -521,7 +521,7 @@ class SkrubNamespace:
         --------
         >>> import skrub
         >>> from skrub import selectors as s
-        >>> X = skrub.X(skrub.toy_orders().X)
+        >>> X = skrub.X(skrub.datasets.toy_orders().X)
         >>> X
         <Var 'X'>
         Result:
@@ -574,7 +574,7 @@ class SkrubNamespace:
         --------
         >>> import skrub
         >>> from skrub import selectors as s
-        >>> X = skrub.X(skrub.toy_orders().X)
+        >>> X = skrub.X(skrub.datasets.toy_orders().X)
         >>> X
         <Var 'X'>
         Result:
@@ -1038,7 +1038,7 @@ class SkrubNamespace:
         Examples
         --------
         >>> import skrub
-        >>> X_df = skrub.toy_orders().X
+        >>> X_df = skrub.datasets.toy_orders().X
         >>> X_df
            ID product  quantity        date
         0   1     pen         2  2020-04-03
@@ -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
@@ -1417,7 +1425,7 @@ class SkrubNamespace:
         --------
         >>> import skrub
         >>> from sklearn.dummy import DummyClassifier
-        >>> orders_df = skrub.toy_orders().orders
+        >>> orders_df = skrub.datasets.toy_orders(split="train").orders
         >>> orders = skrub.var('orders', orders_df)
         >>> X = orders.drop(columns='delayed', errors='ignore').skb.mark_as_X()
         >>> y = orders['delayed'].skb.mark_as_y()
@@ -1434,7 +1442,7 @@ class SkrubNamespace:
         2    False
         3    False
         >>> pipeline = pred.skb.get_pipeline(fitted=True)
-        >>> new_orders_df = skrub.toy_orders(split='test').X
+        >>> new_orders_df = skrub.datasets.toy_orders(split='test').X
         >>> new_orders_df
            ID product  quantity        date
         4   5     cup         5  2020-04-11
@@ -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.
@@ -1513,7 +1521,7 @@ class SkrubNamespace:
         >>> from sklearn.dummy import DummyClassifier
         >>> from sklearn.metrics import accuracy_score
 
-        >>> orders = skrub.var("orders", skrub.toy_orders().orders)
+        >>> orders = skrub.var("orders", skrub.datasets.toy_orders().orders)
         >>> X = orders.skb.drop("delayed").skb.mark_as_X()
         >>> y = orders["delayed"].skb.mark_as_y()
         >>> delayed = X.skb.apply(skrub.TableVectorizer()).skb.apply(
@@ -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,
@@ -2003,7 +2017,7 @@ class SkrubNamespace:
         Examples
         --------
         >>> import skrub
-        >>> orders = skrub.var('orders', skrub.toy_orders(split='all').orders)
+        >>> orders = skrub.var('orders', skrub.datasets.toy_orders(split='all').orders)
         >>> features = orders.drop(columns='delayed', errors='ignore')
         >>> features.skb.is_X
         False
@@ -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,15 +2085,18 @@ 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
-        >>> orders = skrub.var('orders', skrub.toy_orders(split='all').orders)
+        >>> orders = skrub.var('orders', skrub.datasets.toy_orders(split='all').orders)
         >>> X = orders.drop(columns='delayed', errors='ignore').skb.mark_as_X()
         >>> delayed = orders['delayed']
         >>> delayed.skb.is_y
@@ -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
@@ -2256,21 +2273,21 @@ class SkrubNamespace:
         Examples
         --------
         >>> import skrub
-        >>> orders_df = skrub.toy_orders().X
+        >>> orders_df = skrub.datasets.toy_orders().X
         >>> features = skrub.X(orders_df).skb.apply(skrub.TableVectorizer())
         >>> fitted_vectorizer = features.skb.applied_estimator
         >>> fitted_vectorizer
         <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 33eb644..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
 
 
@@ -167,7 +168,7 @@ class GapEncoder(TransformerMixin, SingleColumnTransformer):
     As this is a continuous encoding, we can look at the level of
     activation of each topic for each category:
 
-    >>> enc.transform(X)
+    >>> enc.transform(X) # doctest: +SKIP
        city: england, london, uk  city: france, paris, pqris
     0                   0.051816                   10.548184
     1                   0.050134                    4.549866
@@ -430,8 +431,7 @@ class GapEncoder(TransformerMixin, SingleColumnTransformer):
             raise ValueError("analyzer should be one of ['word', 'char', 'char_wb'].")
 
     def fit(self, X, y=None):
-        """
-        Fit the GapEncoder on `X`.
+        """Fit the GapEncoder and transform a column.
 
         Parameters
         ----------
@@ -442,8 +442,8 @@ class GapEncoder(TransformerMixin, SingleColumnTransformer):
 
         Returns
         -------
-        GapEncoderColumn
-            The fitted GapEncoderColumn instance (self).
+        GapEncoder
+            The fitted GapEncoder instance (self).
         """
         self._check_analyzer()
         self._check_input_type(X)
@@ -457,6 +457,7 @@ class GapEncoder(TransformerMixin, SingleColumnTransformer):
         self._random_state = check_random_state(self.random_state)
         is_null = sbd.to_numpy(sbd.is_null(X))
         X = sbd.to_numpy(X)
+
         # Copy parameter rho
         self.rho_ = self.rho
         # Attributes to monitor the convergence
@@ -468,6 +469,7 @@ class GapEncoder(TransformerMixin, SingleColumnTransformer):
         n_batch = (len(X) - 1) // self.batch_size + 1
         n_samples = len(X)
         del X
+
         # Get activations unq_H
         unq_H = self._get_H(unq_X)
         converged = False
@@ -516,6 +518,9 @@ class GapEncoder(TransformerMixin, SingleColumnTransformer):
 
         # Update self.H_dict_ with the learned encoded vectors (activations)
         self.H_dict_.update(zip(unq_X, unq_H))
+
+        self.scaling_factor_ = _gap_scaling_factor(unq_V)
+
         return self
 
     def get_feature_names_out(self, n_labels=3):
@@ -621,8 +626,8 @@ class GapEncoder(TransformerMixin, SingleColumnTransformer):
 
         Returns
         -------
-        GapEncoderColumn
-            The fitted GapEncoderColumn instance (self).
+        GapEncoder
+            The fitted GapEncoder instance (self).
         """
         self._check_analyzer()
         self._check_input_type(X)
@@ -687,6 +692,11 @@ class GapEncoder(TransformerMixin, SingleColumnTransformer):
         )
         # Update self.H_dict_ with the learned encoded vectors (activations)
         self.H_dict_.update(zip(unq_X, unq_H))
+
+        # XXX: Maybe we should accumulate unq_V or the scaling factor somehow?
+        if not hasattr(self, "scaling_factor_"):
+            self.scaling_factor_ = _gap_scaling_factor(unq_V)
+
         return self
 
     def _add_unseen_keys_to_H_dict(self, X):
@@ -723,10 +733,11 @@ class GapEncoder(TransformerMixin, SingleColumnTransformer):
         self._check_input_type(X, err_type=ValueError)
         is_null = sbd.to_numpy(sbd.is_null(X))
         result = self._transform(sbd.to_numpy(X), is_null)
+        result /= self.scaling_factor_
         names = self.get_feature_names_out()
         result = sbd.make_dataframe_like(X, dict(zip(names, result.T)))
-        result = sbd.copy_index(X, result)
-        return result
+
+        return sbd.copy_index(X, result)
 
     def _check_input_type(self, X, err_type=RejectColumn):
         if sbd.is_categorical(X) or sbd.is_string(X):
@@ -920,3 +931,7 @@ def get_kmeans_prototypes(
     neighbors.fit(projected)
     indexes_prototypes = np.unique(neighbors.kneighbors(centers, 1)[-1])
     return np.sort(X[indexes_prototypes])
+
+
+def _gap_scaling_factor(unq_V):
+    return unq_V.sum(axis=1).mean() / 2
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/_reporting/_data/templates/column-summary.html b/skrub/_reporting/_data/templates/column-summary.html
index acf4650..3d11945 100644
--- a/skrub/_reporting/_data/templates/column-summary.html
+++ b/skrub/_reporting/_data/templates/column-summary.html
@@ -35,7 +35,18 @@
                 {% if not column.value_is_constant %}
                 {% if column.n_unique %}
                 <dt>Unique values</dt>
-                <dd>{{ column.n_unique | format_number }} ({{ column.unique_proportion | format_percent }})</dd>
+                <dd>
+                    {{ column.n_unique | format_number }} ({{ column.unique_proportion | format_percent }})
+                    {% if column.is_high_cardinality %}
+                    <span class="toggletip-wrapper">
+                        <div class="toggletip" data-manager="Toggletip">
+                        <button type="button"><span>i</span></button>
+                        <div class="toggletip-info box-shadow">
+                            This column has a high cardinality (> {{config["cardinality_threshold"]}}).
+                        </div>
+                    </span>
+                    {% endif %}
+                </dd>
                 {% endif %}
 
                 {% if column["duration_unit"] %}
@@ -88,7 +99,7 @@
             <div class="copybutton-grid">
                 <div class="box">
                     <pre id="{{ val_id }}"
-                         data-copy-text="{{ column.constant_value.__repr__() }}">{{ column.constant_value }}</pre>
+                        data-copy-text="{{ column.constant_value.__repr__() }}">{{ column.constant_value }}</pre>
                     {{ buttons.copybutton(val_id) }}
                 </div>
             </div>
diff --git a/skrub/_reporting/_data/templates/dataframe-sample.html b/skrub/_reporting/_data/templates/dataframe-sample.html
index 739409b..9544f6f 100644
--- a/skrub/_reporting/_data/templates/dataframe-sample.html
+++ b/skrub/_reporting/_data/templates/dataframe-sample.html
@@ -66,7 +66,7 @@ data-spans__{{ i }}__{{ j }}
                 {% endif %}
                 >
                 {%- if not (cell["value"]) | is_null -%}
-                {{ cell["value"] }}
+                {{ cell["value"] | format_number }}
                 {%-  if cell["role"] == "columns-level-name" -%}
                 <div class="arrow-icon">{% include "icons/arrow-right.svg" %}</div>
                 {%- endif -%}
diff --git a/skrub/_reporting/_data/templates/summary-statistics.html b/skrub/_reporting/_data/templates/summary-statistics.html
index 589f629..2bd6881 100644
--- a/skrub/_reporting/_data/templates/summary-statistics.html
+++ b/skrub/_reporting/_data/templates/summary-statistics.html
@@ -37,6 +37,7 @@
                     {{ th("Column", "from first column to last column", "from last column to first column", True) }}
                     {{ th("Column name", "by column name from A to Z", "by column name from Z to A", False) }}
                     {{ th("dtype", "by dtype from A to Z", "by dtype from Z to A", False) }}
+                    {{ th("Is sorted", "False on top", "True on top", False)}}
                     {{ th1("Null values", "the fewest null values", "the most null values", True) }}
                     {{ th1("Unique values", "the fewest unique values", "the most unique values", True) }}
                     {{ th1("Mean", "the lowest mean", "the highest mean", True) }}
@@ -54,6 +55,7 @@
                     </td>
                     <td class="elided">{{ column.name }}</td>
                     <td>{{ column.dtype }}</td>
+                    <td>{{column.is_ordered}}</td>
                     <td class="{{ column.nulls_level }}" data-value="{{ column.null_count }}" data-numeric>
                         {{ column.null_count }} ({{ column.null_proportion | format_percent }})
                     </td>
diff --git a/skrub/_reporting/_data/templates/toggletip.css b/skrub/_reporting/_data/templates/toggletip.css
index 9ec6e80..e505901 100644
--- a/skrub/_reporting/_data/templates/toggletip.css
+++ b/skrub/_reporting/_data/templates/toggletip.css
@@ -27,9 +27,7 @@
     box-sizing: border-box;
     position: absolute;
     top: calc(1.3 * var(--height-button));
-    left: calc(0.8 * var(--height-button));
-    width: calc(min(50ch, 100% - var(--height-button)));
-    min-width: min-content;
+    min-width: calc-size(max-content, min(size, 40dvw));
     padding: var(--spacing-l);
     background-color: var(--color-background-primary);
     border: var(--border-m) solid var(--color-background-info);
diff --git a/skrub/_reporting/_html.py b/skrub/_reporting/_html.py
index 70e69af..69a26c5 100644
--- a/skrub/_reporting/_html.py
+++ b/skrub/_reporting/_html.py
@@ -8,6 +8,7 @@ import secrets
 import jinja2
 import pandas as pd
 
+from skrub import _config
 from skrub import _dataframe as sbd
 from skrub import selectors as s
 
@@ -152,6 +153,7 @@ def to_html(summary, standalone=True, column_filters=None, minimal_report_mode=F
             "base64_column_filters": _b64_encode(column_filters),
             "report_id": f"report_{secrets.token_hex()[:8]}",
             "minimal_report_mode": minimal_report_mode,
+            "config": _config.get_config(),
         }
     )
 
diff --git a/skrub/_reporting/_summarize.py b/skrub/_reporting/_summarize.py
index c4d88a4..a279433 100644
--- a/skrub/_reporting/_summarize.py
+++ b/skrub/_reporting/_summarize.py
@@ -1,7 +1,7 @@
 """Get information and plots for a dataframe, that are used to generate reports."""
 import sys
 
-from .. import _column_associations
+from .. import _column_associations, _config
 from .. import _dataframe as sbd
 from . import _plotting, _sample_table, _utils
 
@@ -145,6 +145,7 @@ def _summarize_column(
         "name": sbd.name(column),
         "dtype": _utils.get_dtype_name(column),
         "value_is_constant": False,
+        "is_ordered": False,
     }
     _add_nulls_summary(summary, column, dataframe_summary=dataframe_summary)
     if summary["null_count"] == dataframe_summary["n_rows"]:
@@ -155,6 +156,9 @@ def _summarize_column(
         summary["unique_proportion"] = summary["n_unique"] / max(
             1, dataframe_summary["n_rows"]
         )
+        summary["is_high_cardinality"] = (
+            summary["n_unique"] > _config.get_config()["cardinality_threshold"]
+        )
     except Exception:
         # for some dtypes n_unique can fail eg with a typeerror for
         # non-hashable types in pandas.
@@ -171,6 +175,8 @@ def _summarize_column(
     )
     _add_datetime_summary(summary, column, with_plots=with_plots)
     summary["plot_names"] = [k for k in summary.keys() if k.endswith("_plot")]
+    _add_is_sorted(summary, column)
+
     return summary
 
 
@@ -275,3 +281,7 @@ def _add_numeric_summary(
         )
     else:
         summary["line_plot"] = _plotting.line(order_by_column, column)
+
+
+def _add_is_sorted(summary, column):
+    summary["is_ordered"] = sbd.is_sorted(column)
diff --git a/skrub/_reporting/_utils.py b/skrub/_reporting/_utils.py
index 28c73f2..f18b706 100644
--- a/skrub/_reporting/_utils.py
+++ b/skrub/_reporting/_utils.py
@@ -88,7 +88,11 @@ def format_number(number):
     if isinstance(number, numbers.Integral):
         return f"{number:,}"
     if isinstance(number, numbers.Real):
-        return f"{number:#.3g}"
+        # Import placed here to avoid circular import related to dispatch
+        from skrub._config import get_config
+
+        var = get_config()["float_precision"]
+        return f"{number:#.{var}g}"
     return str(number)
 
 
diff --git a/skrub/_scaling_factor.py b/skrub/_scaling_factor.py
new file mode 100644
index 0000000..6c7814d
--- /dev/null
+++ b/skrub/_scaling_factor.py
@@ -0,0 +1,60 @@
+"""
+Skrub encoders output vectors that don't have the same scale. Scaling using the
+standard deviation of the matrix help computational stability and downstream prediction
+performance.
+"""
+import numpy as np
+
+
+def _clip_epsilon(scaling_factor):
+    # Avoid division by very small or zero values.
+    if scaling_factor < 10 * np.finfo(scaling_factor.dtype).eps:
+        scaling_factor = 1
+    return scaling_factor
+
+
+def scaling_factor(X):
+    r"""Compute the total standard deviation scaler of X.
+
+    This scaling factor is used to normalize the vectors outputs of
+    :class:`StringEncoder`, :class:`TextEncoder` and :class:`GapEncoder`. It is computed
+    during ``fit`` and applied during ``transform``.
+
+    Conceptually, this coefficient corresponds to the square root of the sum of the
+    variances of all columns of X.
+
+    Parameters
+    ----------
+    X : np.ndarray of shape (n_samples, n_features)
+        The matrix to compute the norm from.
+
+    Returns
+    -------
+    scaling_factor : float,
+        The scaling factor.
+
+    See Also
+    --------
+    :class:`~sklearn.preprocessing.Normalizer` :
+        Performs row-wise normalization.
+
+    Notes
+    -----
+    We define the scaler as:
+
+    .. math::
+
+        ||X||_B = \sqrt{\sum_{j=1}^D \sigma^2(X_j)} \in \mathbb{R}
+
+    where:
+
+    * :math:`\sigma^2(X_j)` is the population variance of the column :math:`j`
+    * :math:`D` is the number of features
+
+    When :math:`D = 1`, this scaler is the population standard deviation of the column.
+
+    We then rescale every element of :math:`X` using this scaler.
+    """
+    scaling_factor = np.sqrt(np.nansum(np.nanvar(X, ddof=0, axis=0)))
+
+    return _clip_epsilon(scaling_factor)
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 4ac1db9..9d15de6 100644
--- a/skrub/_string_encoder.py
+++ b/skrub/_string_encoder.py
@@ -9,7 +9,8 @@ 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
 
 
@@ -115,10 +116,10 @@ class StringEncoder(SingleColumnTransformer):
 
     >>> enc.fit_transform(X) # doctest: +SKIP
        video comments_0  video comments_1
-    0      8.218069e-01      4.557474e-17
-    1      6.971618e-16      1.000000e+00
-    2      8.218069e-01     -3.046564e-16
-    """  # noqa: E501
+    0          1.322973         -0.163070
+    1          0.379688          1.659319
+    2          1.306400         -0.317120
+    """
 
     def __init__(
         self,
@@ -209,7 +210,10 @@ class StringEncoder(SingleColumnTransformer):
             result = result.copy()  # To avoid a reference to X_out
         del X_out  # optimize memory: we no longer need X_out
 
-        self._is_fitted = True
+        # block normalize
+        self.scaling_factor_ = scaling_factor(result)
+        result /= self.scaling_factor_
+
         self.n_components_ = result.shape[1]
 
         self.input_name_ = sbd.name(X) or "string_enc"
@@ -249,6 +253,9 @@ class StringEncoder(SingleColumnTransformer):
             result = result.copy()
         del X_out  # optimize memory: we no longer need X_out
 
+        # block normalize
+        result /= self.scaling_factor_
+
         return self._post_process(X, result)
 
     def _post_process(self, X, result):
diff --git a/skrub/_table_vectorizer.py b/skrub/_table_vectorizer.py
index 02cea6f..88e44fd 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.
 
@@ -522,7 +522,7 @@ class TableVectorizer(TransformerMixin, BaseEstimator):
 
     See Also
     --------
-    tabular_learner :
+    tabular_pipeline :
         A function that accepts a scikit-learn estimator and creates a pipeline
         combining a ``TableVectorizer``, optional missing value imputation and
         the provided estimator.
@@ -866,26 +866,16 @@ class TableVectorizer(TransformerMixin, BaseEstimator):
             return steps[-1]
 
         cols = s.all() - self._specific_columns
-
-        self._preprocessors = [CheckInputDataFrame()]
-
-        transformer_list = [CleanNullStrings()]
-        transformer_list.append(
-            DropUninformative(
-                self.drop_if_constant, self.drop_if_unique, self.drop_null_fraction
-            )
+        self._preprocessors = _get_preprocessors(
+            cols=cols,
+            drop_null_fraction=self.drop_null_fraction,
+            drop_if_constant=self.drop_if_constant,
+            drop_if_unique=self.drop_if_unique,
+            n_jobs=self.n_jobs,
+            add_tofloat32=True,
+            datetime_format=self.datetime_format,
         )
 
-        transformer_list += [
-            ToDatetime(format=self.datetime_format),
-            ToFloat32(),
-            CleanCategories(),
-            ToStr(),
-        ]
-
-        for transformer in transformer_list:
-            add_step(self._preprocessors, transformer, cols, allow_reject=True)
-
         self._encoders = []
         self._named_encoders = {}
         for name, selector in [
diff --git a/skrub/_tabular_learner.py b/skrub/_tabular_pipeline.py
similarity index 63%
rename from skrub/_tabular_learner.py
rename to skrub/_tabular_pipeline.py
index 5be7226..6a8e962 100644
--- a/skrub/_tabular_learner.py
+++ b/skrub/_tabular_pipeline.py
@@ -1,3 +1,5 @@
+import warnings
+
 import sklearn
 from sklearn import ensemble
 from sklearn.base import BaseEstimator
@@ -27,13 +29,69 @@ _TREE_ENSEMBLE_CLASSES = (
 def tabular_learner(estimator, *, n_jobs=None):
     """Get a simple machine-learning pipeline for tabular data.
 
+    .. deprecated:: 0.6.0
+        The functionality provided by this function is now implemented in
+        :func:`~skrub.tabular_pipeline`.
+
+    ``'regressor'``, ``'regression'``, ``'classifier'``, ``'classification'``, this
+    function creates a scikit-learn pipeline that extracts numeric features, imputes
+    missing values and scales the data if necessary, then applies the estimator.
+
+    .. note::
+       The heuristics used by the ``tabular_pipeline``
+       to define an appropriate preprocessing based on the ``estimator`` may change
+       in future releases.
+
+    .. versionchanged:: 0.6.0
+        The high cardinality encoder has been changed from
+        :class:`~skrub.MinHashEncoder` to :class:`~skrub.StringEncoder`.
+
+    Parameters
+    ----------
+    estimator : {"regressor", "regression", "classifier", "classification"} or sklearn.base.BaseEstimator
+        The estimator to use as the final step in the pipeline. Based on the type of
+        estimator, the previous preprocessing steps and their respective parameters are
+        chosen. The possible values are:
+
+        - ``'regressor'`` or ``'regression'``: a
+          :obj:`~sklearn.ensemble.HistGradientBoostingRegressor` is used as the final
+          step;
+        - ``'classifier'`` or ``'classification'``: a
+          :obj:`~sklearn.ensemble.HistGradientBoostingClassifier` is used as the final
+          step;
+        - a scikit-learn estimator: the provided estimator is used as the final step.
+
+    n_jobs : int, default=None
+        Number of jobs to run in parallel in the :obj:`TableVectorizer` step. ``None``
+        means 1 unless in a joblib ``parallel_backend`` context. ``-1`` means using all
+        processors.
+
+    Returns
+    -------
+    Pipeline
+        A scikit-learn :obj:`~sklearn.pipeline.Pipeline` chaining some preprocessing and
+        the provided ``estimator``.
+    """  # noqa: E501
+    warnings.warn(
+        (
+            "tabular_learner will be deprecated in the next release. "
+            "Equivalent functionality is available in skrub.set_config."
+        ),
+        category=FutureWarning,
+    )
+    return tabular_pipeline(estimator, n_jobs=n_jobs)
+
+
+def tabular_pipeline(estimator, *, n_jobs=None):
+    """Get a simple machine-learning pipeline for tabular data.
+
     Given either a scikit-learn estimator or one of the special-cased strings
     ``'regressor'``, ``'regression'``, ``'classifier'``, ``'classification'``, this
-    function creates a scikit-learn pipeline that extracts numeric features,
-    imputes missing values and scales the data if necessary, then applies the estimator.
+    function creates a scikit-learn pipeline that extracts numeric features, imputes
+    missing values and scales the data if necessary, then applies the estimator.
 
     .. note::
-       The heuristics used by the ``tabular_learner``
+       The heuristics used by the ``tabular_pipeline``
        to define an appropriate preprocessing based on the ``estimator`` may change
        in future releases.
 
@@ -43,45 +101,44 @@ def tabular_learner(estimator, *, n_jobs=None):
 
     Parameters
     ----------
-    estimator : {"regressor", "regression", "classifier", "classification"} or scikit-learn estimator
+    estimator : {"regressor", "regression", "classifier", "classification"} or sklearn.base.BaseEstimator
         The estimator to use as the final step in the pipeline. Based on the type of
         estimator, the previous preprocessing steps and their respective parameters are
         chosen. The possible values are:
 
-        - ``'regressor'`` or ``'regression'``: a :obj:`~sklearn.ensemble.HistGradientBoostingRegressor`
-          is used as the final step;
-        - ``'classifier'`` or ``'classification'``: a :obj:`~sklearn.ensemble.HistGradientBoostingClassifier`
-          is used as the final step;
+        - ``'regressor'`` or ``'regression'``: a
+          :obj:`~sklearn.ensemble.HistGradientBoostingRegressor` is used as the final
+          step;
+        - ``'classifier'`` or ``'classification'``: a
+          :obj:`~sklearn.ensemble.HistGradientBoostingClassifier` is used as the final
+          step;
         - a scikit-learn estimator: the provided estimator is used as the final step.
 
     n_jobs : int, default=None
-        Number of jobs to run in parallel in the :obj:`TableVectorizer` step.
-        ``None`` means 1 unless in a joblib ``parallel_backend`` context.
-        ``-1`` means using all processors.
+        Number of jobs to run in parallel in the :obj:`TableVectorizer` step. ``None``
+        means 1 unless in a joblib ``parallel_backend`` context. ``-1`` means using all
+        processors.
 
     Returns
     -------
     Pipeline
-        A scikit-learn :obj:`~sklearn.pipeline.Pipeline` chaining some
-        preprocessing and the provided ``estimator``.
+        A scikit-learn :obj:`~sklearn.pipeline.Pipeline` chaining some preprocessing and
+        the provided ``estimator``.
 
     Notes
     -----
 
-    ``tabular_learner`` returns a scikit-learn :obj:`~sklearn.pipeline.Pipeline`
-    with several steps:
-
-    - A :obj:`TableVectorizer` transforms the tabular data into numeric
-      features. Its parameters are chosen depending on the provided
-      ``estimator``.
-    - An optional :obj:`~sklearn.impute.SimpleImputer` imputes missing values
-      by their mean and adds binary columns that indicate which values were
-      missing. This step is only added if the ``estimator`` cannot handle
-      missing values itself.
-    - An optional :obj:`~sklearn.preprocessing.StandardScaler` centers and
-      rescales the data. This step is not added (because it is unnecessary) when
-      the ``estimator`` is a tree ensemble such as random forest or gradient
-      boosting.
+    ``tabular_pipeline`` returns a scikit-learn :obj:`~sklearn.pipeline.Pipeline` with
+    several steps:
+
+    - A :obj:`TableVectorizer` transforms the tabular data into numeric features. Its
+      parameters are chosen depending on the provided ``estimator``.
+    - An optional :obj:`~sklearn.impute.SimpleImputer` imputes missing values by their
+      mean and adds binary columns that indicate which values were missing. This step is
+      only added if the ``estimator`` cannot handle missing values itself.
+    - An optional :obj:`~sklearn.preprocessing.StandardScaler` centers and rescales the
+      data. This step is not added (because it is unnecessary) when the ``estimator`` is
+      a tree ensemble such as random forest or gradient boosting.
     - The last step is the provided ``estimator``.
 
     The parameter values for the :obj:`TableVectorizer` might differ depending on the
@@ -89,25 +146,24 @@ def tabular_learner(estimator, *, n_jobs=None):
 
     - support for categorical features in
       :class:`~sklearn.ensemble.HistGradientBoostingClassifier` and
-      :class:`~sklearn.ensemble.HistGradientBoostingRegressor` was added in
-      scikit-learn 1.4. Therefore, before this version, a
+      :class:`~sklearn.ensemble.HistGradientBoostingRegressor` was added in scikit-learn
+      1.4. Therefore, before this version, a
       :class:`~sklearn.preprocessing.OrdinalEncoder` is used for low-cardinality
       features.
-    - support for missing values in
-      :class:`~sklearn.ensemble.RandomForestClassifier` and
-      :class:`~sklearn.ensemble.RandomForestRegressor` was added in scikit-learn 1.4.
-      Therefore, before this version, a :class:`~sklearn.impute.SimpleImputer` is used
-      to impute missing values.
+    - support for missing values in :class:`~sklearn.ensemble.RandomForestClassifier`
+      and :class:`~sklearn.ensemble.RandomForestRegressor` was added in scikit-learn
+      1.4. Therefore, before this version, a :class:`~sklearn.impute.SimpleImputer` is
+      used to impute missing values.
 
     Read more in the :ref:`User Guide <table_vectorizer>`.
 
     Examples
     --------
-    >>> from skrub import tabular_learner
+    >>> from skrub import tabular_pipeline
 
     We can easily get a default pipeline for regression or classification:
 
-    >>> tabular_learner('regression')                                    # doctest: +SKIP
+    >>> tabular_pipeline('regression')                                    # doctest: +SKIP
     Pipeline(steps=[('tablevectorizer',
                      TableVectorizer(high_cardinality=StringEncoder(),
                                      low_cardinality=ToCategorical())),
@@ -117,7 +173,7 @@ def tabular_learner(estimator, *, n_jobs=None):
     When requesting a ``'regression'``, the last step of the pipeline is set to a
     :obj:`~sklearn.ensemble.HistGradientBoostingRegressor`.
 
-    >>> tabular_learner('classification')                                   # doctest: +SKIP
+    >>> tabular_pipeline('classification')                                   # doctest: +SKIP
     Pipeline(steps=[('tablevectorizer',
                      TableVectorizer(high_cardinality=StringEncoder(),
                                      low_cardinality=ToCategorical())),
@@ -146,7 +202,7 @@ def tabular_learner(estimator, *, n_jobs=None):
     2  2024-12-05  paracetamol                     0               44
     3  2023-08-10   gliclazide                    17              137
 
-    >>> model = tabular_learner('classifier').fit(X, y)
+    >>> model = tabular_pipeline('classifier').fit(X, y)
     >>> model.predict(X)
     array([0, 0, 0, 0])
 
@@ -154,7 +210,7 @@ def tabular_learner(estimator, *, n_jobs=None):
     estimator:
 
     >>> from sklearn.linear_model import LogisticRegression
-    >>> model = tabular_learner(LogisticRegression())
+    >>> model = tabular_pipeline(LogisticRegression())
     >>> model.fit(X, y)
     Pipeline(steps=[('tablevectorizer',
                     TableVectorizer(datetime=DatetimeEncoder(periodic_encoding='spline'))),
@@ -175,7 +231,7 @@ def tabular_learner(estimator, *, n_jobs=None):
 
     The parameters of the :obj:`TableVectorizer` depend on the provided ``estimator``.
 
-    >>> tabular_learner(LogisticRegression())
+    >>> tabular_pipeline(LogisticRegression())
     Pipeline(steps=[('tablevectorizer',
                     TableVectorizer(datetime=DatetimeEncoder(periodic_encoding='spline'))),
                     ('simpleimputer', SimpleImputer(add_indicator=True)),
@@ -184,43 +240,41 @@ def tabular_learner(estimator, *, n_jobs=None):
 
     For a :obj:`~sklearn.linear_model.LogisticRegression`, we get:
 
-    - a default configuration of the :obj:`TableVectorizer` which is intended
-      to work well for a wide variety of downstream estimators. The configuration
-      adds ``spline`` periodic features to datetime columns.
+    - a default configuration of the :obj:`TableVectorizer` which is intended to work
+      well for a wide variety of downstream estimators. The configuration adds
+      ``spline`` periodic features to datetime columns.
 
     - A :obj:`~sklearn.impute.SimpleImputer`, as the
       :obj:`~sklearn.linear_model.LogisticRegression` cannot handle missing values.
 
-    - A :obj:`~sklearn.preprocessing.StandardScaler` for centering and standard
-      scaling numerical features.
+    - A :obj:`~sklearn.preprocessing.StandardScaler` for centering and standard scaling
+      numerical features.
 
     On the other hand, For the :obj:`~sklearn.ensemble.HistGradientBoostingClassifier`
     (generated with the string ``"classifier"``):
 
-    >>> tabular_learner('classifier')                                   # doctest: +SKIP
+    >>> tabular_pipeline('classifier')                                   # doctest: +SKIP
     Pipeline(steps=[('tablevectorizer',
                      TableVectorizer(high_cardinality=StringEncoder(),
                                      low_cardinality=ToCategorical())),
                     ('histgradientboostingclassifier',
                      HistGradientBoostingClassifier(categorical_features='from_dtype'))])
 
-    - A :obj:`StringEncoder` is used as the ``high_cardinality`` encoder. This
-      encoder strikes a good balance between quality and performance in most
-      situations.
+    - A :obj:`StringEncoder` is used as the ``high_cardinality`` encoder. This encoder
+      strikes a good balance between quality and performance in most situations.
 
-    - The ``low_cardinality`` does not one-hot encode features.
-      The :obj:`~sklearn.ensemble.HistGradientBoostingClassifier` has built-in
-      support for categorical data which is more efficient than one-hot
-      encoding. Therefore the selected encoder, :obj:`ToCategorical`, simply
-      makes sure that those features have a categorical dtype so that the
-      :obj:`~sklearn.ensemble.HistGradientBoostingClassifier` recognizes them
-      as such.
+    - The ``low_cardinality`` does not one-hot encode features. The
+      :obj:`~sklearn.ensemble.HistGradientBoostingClassifier` has built-in support for
+      categorical data which is more efficient than one-hot encoding. Therefore the
+      selected encoder, :obj:`ToCategorical`, simply makes sure that those features have
+      a categorical dtype so that the
+      :obj:`~sklearn.ensemble.HistGradientBoostingClassifier` recognizes them as such.
 
     - There is no spline encoding of datetimes.
 
-    - There is no missing-value imputation because the classifier has its own
-      (better) mechanism for dealing with missing values, and no standard scaling
-      because it is unnecessary for tree ensembles.
+    - There is no missing-value imputation because the classifier has its own (better)
+      mechanism for dealing with missing values, and no standard scaling because it is
+      unnecessary for tree ensembles.
     """  # noqa: E501
     vectorizer = TableVectorizer(n_jobs=n_jobs)
     if parse_version(sklearn.__version__) < parse_version("1.4"):
@@ -230,12 +284,12 @@ def tabular_learner(estimator, *, n_jobs=None):
 
     if isinstance(estimator, str):
         if estimator in ("classifier", "classification"):
-            return tabular_learner(
+            return tabular_pipeline(
                 ensemble.HistGradientBoostingClassifier(**cat_feat_kwargs),
                 n_jobs=n_jobs,
             )
         if estimator in ("regressor", "regression"):
-            return tabular_learner(
+            return tabular_pipeline(
                 ensemble.HistGradientBoostingRegressor(**cat_feat_kwargs),
                 n_jobs=n_jobs,
             )
@@ -245,13 +299,13 @@ def tabular_learner(estimator, *, n_jobs=None):
         )
     if isinstance(estimator, type) and issubclass(estimator, BaseEstimator):
         raise TypeError(
-            "tabular_learner expects a scikit-learn estimator as its first"
+            "tabular_pipeline expects a scikit-learn estimator as its first"
             f" argument. Pass an instance of {estimator.__name__} rather than the class"
             " itself."
         )
     if not isinstance(estimator, BaseEstimator):
         raise TypeError(
-            "tabular_learner expects a scikit-learn estimator, 'regressor',"
+            "tabular_pipeline expects a scikit-learn estimator, 'regressor',"
             " or 'classifier' as its first argument."
         )
 
diff --git a/skrub/_text_encoder.py b/skrub/_text_encoder.py
index b07fd71..2c0f734 100644
--- a/skrub/_text_encoder.py
+++ b/skrub/_text_encoder.py
@@ -9,7 +9,8 @@ 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
 from .datasets._utils import get_data_dir
@@ -169,7 +170,9 @@ class TextEncoder(SingleColumnTransformer, TransformerMixin):
 
     Let's encode video comments using only 2 embedding dimensions:
 
-    >>> enc = TextEncoder(model_name='intfloat/e5-small-v2', n_components=2)
+    >>> enc = TextEncoder(
+    ...    model_name='intfloat/e5-small-v2', n_components=2
+    ... )
     >>> X = pd.Series([
     ...   "The professor snatched a good interview out of the jaws of these questions.",
     ...   "Bookmarking this to watch later.",
@@ -262,6 +265,10 @@ class TextEncoder(SingleColumnTransformer, TransformerMixin):
                 # number of dimensions of X_out.
                 X_out = X_out[:, : self.n_components]
 
+        # block normalize
+        self.scaling_factor_ = scaling_factor(X_out)
+        X_out /= self.scaling_factor_
+
         self.n_components_ = X_out.shape[1]
 
         cols = self.get_feature_names_out()
@@ -303,6 +310,9 @@ class TextEncoder(SingleColumnTransformer, TransformerMixin):
         elif self.n_components is not None:
             X_out = X_out[:, : self.n_components]
 
+        # block scale
+        X_out /= self.scaling_factor_
+
         cols = self.get_feature_names_out()
         X_out = sbd.make_dataframe_like(column, dict(zip(cols, X_out.T)))
         X_out = sbd.copy_index(column, X_out)
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 56f5094..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"]
@@ -145,7 +145,7 @@ class ToDatetime(SingleColumnTransformer):
     2    2024-05-07T13:17:52
     Name: when, dtype: object
 
-    >>> from skrub._to_datetime import ToDatetime
+    >>> from skrub import ToDatetime
 
     >>> to_dt = ToDatetime()
     >>> to_dt.fit_transform(s)
@@ -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_errors.py b/skrub/_expressions/tests/test_errors.py
index a44a6f0..7f9c01c 100644
--- a/skrub/_expressions/tests/test_errors.py
+++ b/skrub/_expressions/tests/test_errors.py
@@ -46,7 +46,7 @@ def test_setitem():
     a = skrub.var("a", {})
     with pytest.raises(TypeError, match="Do not modify an expression in-place"):
         a["one"] = 1
-    a = skrub.var("a", skrub.toy_orders().orders)
+    a = skrub.var("a", skrub.datasets.toy_orders().orders)
     with pytest.raises(
         TypeError, match=r"(?s)Do not modify an expression in-place.*df = df\.assign"
     ):
@@ -314,7 +314,7 @@ def test_attribute_errors():
 
 
 def test_concat_horizontal_numpy():
-    a = skrub.var("a", skrub.toy_orders().orders)
+    a = skrub.var("a", skrub.datasets.toy_orders().orders)
     b = skrub.var("b", np.eye(3))
     with pytest.raises(Exception, match=".*can only be used with dataframes"):
         b.skb.concat([a], axis=1)
@@ -323,7 +323,7 @@ def test_concat_horizontal_numpy():
 
 
 def test_concat_vertical_numpy():
-    a = skrub.var("a", skrub.toy_orders().orders)
+    a = skrub.var("a", skrub.datasets.toy_orders().orders)
     b = skrub.var("b", np.eye(3))
     with pytest.raises(Exception, match=".*can only be used with dataframes"):
         b.skb.concat([a], axis=0)
@@ -332,7 +332,7 @@ def test_concat_vertical_numpy():
 
 
 def test_concat_needs_wrapping_in_list():
-    a = skrub.var("a", skrub.toy_orders().orders)
+    a = skrub.var("a", skrub.datasets.toy_orders().orders)
     with pytest.raises(Exception, match=".*should be passed a list of dataframes"):
         a.skb.concat(a, axis=1)
     with pytest.raises(Exception, match=".*should be passed a list of dataframes"):
@@ -340,7 +340,7 @@ def test_concat_needs_wrapping_in_list():
 
 
 def test_concat_axis_undefined():
-    a = skrub.var("a", skrub.toy_orders().orders)
+    a = skrub.var("a", skrub.datasets.toy_orders().orders)
     with pytest.raises(Exception, match=".*one of 0 or 1"):
         a.skb.concat([a], axis=2)
     with pytest.raises(Exception, match=".*one of 0 or 1"):
@@ -348,7 +348,7 @@ def test_concat_axis_undefined():
 
 
 def test_apply_instead_of_skb_apply():
-    a = skrub.var("a", skrub.toy_orders().orders)
+    a = skrub.var("a", skrub.datasets.toy_orders().orders)
     with pytest.raises(Exception, match=r".*Did you mean `\.skb\.apply\(\)`"):
         a.apply("passthrough")
     with pytest.raises(Exception, match=r".*Did you mean `\.skb\.apply\(\)`"):
@@ -428,7 +428,7 @@ def test_bad_names():
 
 
 def test_pass_df_instead_of_expr():
-    df = skrub.toy_orders().orders
+    df = skrub.datasets.toy_orders().orders
     with pytest.raises(TypeError, match="You passed an actual DataFrame"):
         skrub.var("a").merge(df, on="ID")
     # this one is raised by pandas so we do not control the type or error
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_inspection.py b/skrub/_expressions/tests/test_inspection.py
index 1aefc6f..84aaf63 100644
--- a/skrub/_expressions/tests/test_inspection.py
+++ b/skrub/_expressions/tests/test_inspection.py
@@ -69,7 +69,7 @@ def test_preview_subsample():
 def test_full_report_failed_apply():
     # Somewhat contrived example for the corner case where an Apply does not
     # have an easily identifiable estimator.
-    orders = skrub.toy_orders()
+    orders = skrub.datasets.toy_orders()
     e = (
         skrub.X()
         .skb.apply(SelectKBest())  # error! missing y
diff --git a/skrub/_expressions/tests/test_interactive_features.py b/skrub/_expressions/tests/test_interactive_features.py
index ac277c4..e7d59ae 100644
--- a/skrub/_expressions/tests/test_interactive_features.py
+++ b/skrub/_expressions/tests/test_interactive_features.py
@@ -71,7 +71,7 @@ def test_repr_html():
     if "Please install" in r:
         pytest.skip("graphviz not installed")
     assert "thename" in r and "thevalue" in r
-    a = skrub.var("thename", skrub.toy_orders().orders)
+    a = skrub.var("thename", skrub.datasets.toy_orders().orders)
     r = a._repr_html_()
     assert "thename" in r and "table-report" in r
     r = a["quantity"]._repr_html_()
@@ -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/_reporting/tests/test_summarize.py b/skrub/_reporting/tests/test_summarize.py
index 5178962..cd74974 100644
--- a/skrub/_reporting/tests/test_summarize.py
+++ b/skrub/_reporting/tests/test_summarize.py
@@ -57,6 +57,7 @@ def test_summarize(
     assert c == {
         "idx": 0,
         "dtype": "string",
+        "is_ordered": False,
         "n_unique": 2,
         "name": "city",
         "null_count": 0,
@@ -69,6 +70,7 @@ def test_summarize(
         "most_frequent_values": ["Paris", "London"],
         "value_is_constant": False,
         "is_duration": False,
+        "is_high_cardinality": False,
     }
 
     assert summary["columns"][4]["constant_value"] == "no2"
@@ -252,3 +254,16 @@ def test_duplicate_columns(pd_module):
     assert cols[0]["mean"] == 1.5
     assert cols[1]["name"] == "a"
     assert cols[1]["mean"] == 3.5
+
+
+def test_high_cardinality_columns(df_module):
+    df = df_module.make_dataframe(
+        {
+            "low-cardinality": [0] * 100,
+            "high-cardinality": range(100),
+        }
+    )
+    summary = summarize_dataframe(df)
+    cols = summary["columns"]
+    assert not cols[0]["is_high_cardinality"]
+    assert cols[1]["is_high_cardinality"]
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_config.py b/skrub/tests/test_config.py
index f2f11e6..e2c7d0b 100644
--- a/skrub/tests/test_config.py
+++ b/skrub/tests/test_config.py
@@ -18,6 +18,8 @@ def test_config_context():
         "max_association_columns": 30,
         "subsampling_seed": 0,
         "enable_subsampling": "default",
+        "float_precision": 3,
+        "cardinality_threshold": 40,
     }
 
     # Not using as a context manager affects nothing
@@ -87,6 +89,26 @@ def test_enable_subsampling():
             assert evaluate(expr.skb.subsample(n=3), mode="preview").shape[0] == 3
 
 
+def test_float_precision():
+    y = fetch_employee_salaries().y
+
+    # Default config: float_precision set to 3
+    report = TableReport(y)
+    mean = f"{report._summary['columns'][0]['mean']:#.3g}"
+    html = report._repr_html_()
+    assert mean in html
+
+    # Float precision set to 2
+    with config_context(float_precision=2):
+        report_2 = TableReport(y)
+        mean_2 = f"{report_2._summary['columns'][0]['mean']:#.2g}"
+        html_2 = report_2._repr_html_()
+        assert mean_2 in html_2
+
+    assert mean != mean_2
+    assert html != html_2
+
+
 @pytest.mark.parametrize(
     "params",
     [
@@ -96,6 +118,8 @@ def test_enable_subsampling():
         {"max_association_columns": "hello"},
         {"subsampling_seed": -1},
         {"enable_subsampling": "no"},
+        {"float_precision": -1},
+        {"cardinality_threshold": -1},
     ],
 )
 def test_error(params):
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 05a6a59..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
 
@@ -145,7 +145,10 @@ def test_partial_fit(df_module, add_words, generate_data):
     X3 = generate_data(n_samples - 10, random_state=2)
     # Gap encoder with fit on one batch
     enc = GapEncoder(
-        random_state=42, batch_size=n_samples, max_iter=1, add_words=add_words
+        random_state=42,
+        batch_size=n_samples,
+        max_iter=1,
+        add_words=add_words,
     )
     X_enc = enc.fit_transform(X)
     # Gap encoder with partial fit
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_scaling_factor.py b/skrub/tests/test_scaling_factor.py
new file mode 100644
index 0000000..34b9fab
--- /dev/null
+++ b/skrub/tests/test_scaling_factor.py
@@ -0,0 +1,72 @@
+import numpy as np
+import pytest
+from numpy.testing import assert_array_almost_equal
+
+import skrub
+import skrub._dataframe as sbd
+from skrub import GapEncoder, StringEncoder
+from skrub._scaling_factor import scaling_factor
+
+try:
+    import sentence_transformers  # noqa: F401
+
+    TRANSFORMERS = True
+except ImportError:
+    TRANSFORMERS = False
+
+
+def test_scaling_invariance():
+    """Normalizing twice leads to a unit scale."""
+    X = np.random.randn(10, 3)
+    scale_1 = scaling_factor(X)
+    X_scale_1 = X / scale_1
+
+    scale_2 = scaling_factor(X_scale_1)
+    X_scale_2 = X_scale_1 / scale_2
+
+    assert_array_almost_equal(X_scale_1, X_scale_2)
+
+    assert scale_2 == pytest.approx(1)
+
+
+def test_nonfinite():
+    """Nonfinite values are equivalent to removing these value
+    column-wise.
+    """
+    X = np.array([[np.nan, np.nan, np.nan], [1, 2, 3], [4, 5, 6]])
+    assert scaling_factor(X) == scaling_factor(X[1:])
+
+
+@pytest.mark.parametrize(
+    "encoder",
+    [
+        StringEncoder(n_components=2),
+        GapEncoder(n_components=2),
+        pytest.param(
+            skrub.TextEncoder(
+                model_name="sentence-transformers/paraphrase-albert-small-v2",
+                n_components=2,
+                device="cpu",
+            ),
+            marks=pytest.mark.skipif(
+                not TRANSFORMERS, reason="transformers not installed"
+            ),
+        ),
+    ],
+)
+def test_encoders(df_module, encoder):
+    X = df_module.example_dataframe["str-col"]
+    X_t = encoder.fit_transform(X)
+    assert scaling_factor(np.array(X_t)) == pytest.approx(1, abs=0.1)
+
+
+def test_partial_fit(df_module):
+    X = df_module.example_dataframe["str-col"]
+    X1 = sbd.slice(X, 0, 2)
+    X2 = sbd.slice(X, 2, 4)
+
+    gap = GapEncoder(n_components=2)
+    gap.partial_fit(X1)
+    gap.partial_fit(X2)
+
+    assert gap.scaling_factor_ == pytest.approx(0.75)
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 c85b2ef..73a2fd4 100644
--- a/skrub/tests/test_string_encoder.py
+++ b/skrub/tests/test_string_encoder.py
@@ -11,7 +11,8 @@ 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
 
 
 @pytest.fixture
@@ -41,6 +42,7 @@ def test_tfidf_vectorizer(encode_column, df_module):
     )
     check = pipe.fit_transform(sbd.to_numpy(sbd.fill_nulls(encode_column, "")))
     check = check.astype("float32")  # StringEncoder is float32
+    check /= scaling_factor(check)
 
     names = [f"col1_{idx}" for idx in range(2)]
 
diff --git a/skrub/tests/test_tabular_learner.py b/skrub/tests/test_tabular_learner.py
index 6aa508d..c613bbc 100644
--- a/skrub/tests/test_tabular_learner.py
+++ b/skrub/tests/test_tabular_learner.py
@@ -11,6 +11,7 @@ from skrub import (
     TableVectorizer,
     ToCategorical,
     tabular_learner,
+    tabular_pipeline,
 )
 
 
@@ -18,7 +19,7 @@ from skrub import (
     "learner_kind", ["regressor", "regression", "classifier", "classification"]
 )
 def test_default_pipeline(learner_kind):
-    p = tabular_learner(learner_kind)
+    p = tabular_pipeline(learner_kind)
     tv, learner = [e for _, e in p.steps]
     assert isinstance(tv, TableVectorizer)
     assert isinstance(tv.high_cardinality, StringEncoder)
@@ -38,18 +39,18 @@ def test_bad_learner():
         ValueError,
         match=".*should be 'regressor', 'regression', 'classifier' or 'classification'",
     ):
-        tabular_learner("bad")
+        tabular_pipeline("bad")
     with pytest.raises(
         TypeError, match=".*Pass an instance of HistGradientBoostingRegressor"
     ):
-        tabular_learner(ensemble.HistGradientBoostingRegressor)
+        tabular_pipeline(ensemble.HistGradientBoostingRegressor)
     with pytest.raises(TypeError, match=".*expects a scikit-learn estimator"):
-        tabular_learner(object())
+        tabular_pipeline(object())
 
 
 def test_linear_learner():
     original_learner = Ridge()
-    p = tabular_learner(original_learner)
+    p = tabular_pipeline(original_learner)
     tv, imputer, scaler, learner = [e for _, e in p.steps]
     assert learner is original_learner
     assert isinstance(tv.high_cardinality, StringEncoder)
@@ -61,7 +62,7 @@ def test_linear_learner():
 
 def test_tree_learner():
     original_learner = ensemble.RandomForestClassifier()
-    p = tabular_learner(original_learner)
+    p = tabular_pipeline(original_learner)
     if parse_version(sklearn.__version__) < parse_version("1.4"):
         tv, impute, learner = [e for _, e in p.steps]
         assert isinstance(impute, SimpleImputer)
@@ -74,11 +75,19 @@ def test_tree_learner():
 
 
 def test_from_dtype():
-    p = tabular_learner(ensemble.HistGradientBoostingRegressor(categorical_features=()))
+    p = tabular_pipeline(
+        ensemble.HistGradientBoostingRegressor(categorical_features=())
+    )
     assert isinstance(p.named_steps["tablevectorizer"].low_cardinality, OrdinalEncoder)
     if parse_version(sklearn.__version__) < parse_version("1.4"):
         return
-    p = tabular_learner(
+    p = tabular_pipeline(
         ensemble.HistGradientBoostingRegressor(categorical_features="from_dtype")
     )
     assert isinstance(p.named_steps["tablevectorizer"].low_cardinality, ToCategorical)
+
+
+def test_warning_table_learner():
+    msg = "tabular_learner will be deprecated in the next release"
+    with pytest.raises(FutureWarning, match=msg):
+        _ = tabular_learner("regressor")
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_utils.py b/skrub/tests/test_utils.py
index bf516b1..24ef1cb 100644
--- a/skrub/tests/test_utils.py
+++ b/skrub/tests/test_utils.py
@@ -4,7 +4,6 @@ import numpy as np
 import pandas as pd
 import pytest
 
-import skrub
 from skrub import _dataframe as sbd
 from skrub import _utils
 from skrub._utils import (
@@ -12,6 +11,7 @@ from skrub._utils import (
     import_optional_dependency,
     unique_strings,
 )
+from skrub.datasets import toy_orders
 
 
 def test_lrudict():
@@ -67,7 +67,7 @@ def test_short_repr():
     for i in range(10):
         d = {0: d}
     assert _utils.short_repr(d) == "{0: {0: {0: {0: {0: {0: {...}}}}}}}"
-    df = skrub.toy_orders().X
+    df = toy_orders().X
     assert _utils.short_repr(df) == "DataFrame(...)"
 
     class A:
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)