Repository: skrub-data/skrub
Pull request #1479 (2025-06-27 21:46:30)

DOC Rephrasing of employee_salaries dataset description
Closes #1472.

Users that want to load this dataset directly from OpenML can now easily find the reference.

Also, this PR proposes removing a promise I am not sure skrub maintainers are planning to keep.


Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index aab958c..58fa5be 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -63,6 +63,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 +96,8 @@ Changes
 - The :class:`Cleaner` now exposes a parameter to convert numerical values to float32. :pr:`1440` by
   :user:`Riccardo Cappuzzo<rcap107>`.
 
+- 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/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/skrub/_config.py b/skrub/_config.py
index c5dc1af..031a977 100644
--- a/skrub/_config.py
+++ b/skrub/_config.py
@@ -14,6 +14,7 @@ _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)),
 }
 _threadlocal = threading.local()
 
@@ -70,6 +71,7 @@ def set_config(
     max_association_columns=None,
     subsampling_seed=None,
     enable_subsampling=None,
+    float_precision=None,
 ):
     """Set global skrub configuration.
 
@@ -131,6 +133,13 @@ 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.
+
     See Also
     --------
     get_config : Retrieve current values for global configuration.
@@ -184,6 +193,13 @@ 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
+
     _apply_external_patches(local_config)
 
 
@@ -196,6 +212,7 @@ def config_context(
     max_association_columns=None,
     subsampling_seed=None,
     enable_subsampling=None,
+    float_precision=None,
 ):
     """Context manager for global skrub configuration.
 
@@ -257,6 +274,13 @@ 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.
+
     Yields
     ------
     None.
@@ -280,6 +304,7 @@ def config_context(
         max_association_columns=max_association_columns,
         subsampling_seed=subsampling_seed,
         enable_subsampling=enable_subsampling,
+        float_precision=float_precision,
     )
 
     try:
diff --git a/skrub/_gap_encoder.py b/skrub/_gap_encoder.py
index 33eb644..99e81db 100644
--- a/skrub/_gap_encoder.py
+++ b/skrub/_gap_encoder.py
@@ -167,7 +167,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 +430,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 +441,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 +456,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 +468,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 +517,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 +625,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 +691,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 +732,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 +930,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/_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/dataframe-sample.css b/skrub/_reporting/_data/templates/dataframe-sample.css
index 0c81553..38bac83 100644
--- a/skrub/_reporting/_data/templates/dataframe-sample.css
+++ b/skrub/_reporting/_data/templates/dataframe-sample.css
@@ -1,200 +1,209 @@
 /* The table showing the first few and last rows of the dataframe */
 /* -------------------------------------------------------------- */
 
-td, th {
-    width: var(--spacing-xl);
+td,
+th {
+  width: var(--spacing-xl);
 }
 
 tr {
-    height: var(--font-size-xs);
+  height: var(--font-size-xs);
 }
 
 .table-with-selectable-cells {
-    --arrow-width: var(--base-size);
+  --arrow-width: var(--base-size);
 }
 
 .table-with-selectable-cells {
-    margin: var(--border-m);
-    border: none;
+  margin: var(--border-m);
+  border: none;
 }
 
 .table-with-selectable-cells tbody {
-    border: var(--border-s) solid var(--color-border-secondary);
+  border: var(--border-s) solid var(--color-border-secondary);
 }
 
 .table-with-selectable-cells .table-cell {
-    transition: background-color var(--animation-duration)
-        var(--animation-easing);
+  transition: background-color var(--animation-duration) var(--animation-easing);
 }
 
 .table-with-selectable-cells thead th {
-    vertical-align: top;
-    border: var(--border-s) solid var(--color-border-secondary);
-    background-color: var(--color-background-quaternary);
+  vertical-align: top;
+  border: var(--border-s) solid var(--color-border-secondary);
+  background-color: var(--color-background-quaternary);
 }
 
 .table-with-selectable-cells tbody tr {
-    background-color: var(--color-background-tertiary);
+  background-color: var(--color-background-tertiary);
 }
 
 .table-with-selectable-cells tbody tr:nth-child(2n -1) {
-    background-color: var(--color-background-secondary);
+  background-color: var(--color-background-secondary);
 }
 
 .table-with-selectable-cells thead tr .table-cell[data-is-in-active-column],
 .table-with-selectable-cells tbody tr .table-cell[data-is-in-active-column] {
-    background-color: var(--color-background-highlight-primary);
+  background-color: var(--color-background-highlight-primary);
 }
 
 .table-with-selectable-cells
-    tbody
-    tr:nth-child(2n -1)
-    .table-cell[data-is-in-active-column] {
-    background-color: color-mix(
-        in srgb,
-        var(--color-background-highlight-primary),
-        var(--color-background-primary)
-    );
+  tbody
+  tr:nth-child(2n -1)
+  .table-cell[data-is-in-active-column] {
+  background-color: color-mix(
+    in srgb,
+    var(--color-background-highlight-primary),
+    var(--color-background-primary)
+  );
 }
 
 .table-with-selectable-cells thead tr .table-cell[data-is-active],
 .table-with-selectable-cells thead tr .table-cell[data-is-active]:focus,
 .table-with-selectable-cells tbody tr .table-cell[data-is-active],
 .table-with-selectable-cells tbody tr .table-cell[data-is-active]:focus {
-    background-color: var(--color-background-active);
+  background-color: var(--color-background-active);
 }
 
 .table-with-selectable-cells th[data-role="padding"] {
-    background-color: var(--color-background-primary);
-    border: none;
-    transition: none;
+  background-color: var(--color-background-primary);
+  border: none;
+  transition: none;
 }
 
 #report .table-with-selectable-cells th[data-role="columns-level-name"] {
-    text-align: right;
-    padding-right: calc(calc(2 * var(--spacing-s)) + var(--arrow-width));
-    position: relative;
+  text-align: right;
+  padding-right: calc(calc(2 * var(--spacing-s)) + var(--arrow-width));
+  position: relative;
 }
 
 .table-with-selectable-cells th[data-role="columns-level-name"] > .arrow-icon {
-    position: absolute;
-    width: var(--arrow-width);
-    height: 100%;
-    top: 0;
-    bottom: 0;
-    left: 100%;
-    right: calc(-1 * var(--arrow-width));
-    transform: translateX(calc(-1 * var(--spacing-s) - var(--arrow-width)));
-    display: flex;
-    user-select: none;
+  position: absolute;
+  width: var(--arrow-width);
+  height: 100%;
+  top: 0;
+  bottom: 0;
+  left: 100%;
+  right: calc(-1 * var(--arrow-width));
+  transform: translateX(calc(-1 * var(--spacing-s) - var(--arrow-width)));
+  display: flex;
+  user-select: none;
 }
 
 .table-with-selectable-cells th[data-role="index-level-name"],
 .table-with-selectable-cells th[data-role="index-level-value"] {
-    background-color: var(--color-background-primary);
-    border: var(--border-s) solid var(--color-border-secondary);
-    text-align: right;
+  background-color: var(--color-background-primary);
+  border: var(--border-s) solid var(--color-border-secondary);
+  text-align: right;
 }
 
 table:not(:focus-within):not(:has(:active)) .table-cell[data-is-active] {
-    outline: var(--border-m) dotted;
+  outline: var(--border-m) dotted;
 }
 
 .table-cell[data-is-active]:focus {
-    outline-width: var(--border-m);
-    outline-style: solid;
+  outline-width: var(--border-m);
+  outline-style: solid;
 }
 
 #report .table-cell[data-is-active][data-just-copied]:focus {
-    outline-width: var(--border-m);
-    outline-style: dashed;
+  outline-width: var(--border-m);
+  outline-style: dashed;
 }
 
 /* The gap separating the head and the tail of the table */
 
 #report .elided-table-part,
 #report .elided-table-part td {
-    border: var(--border-m) dashed var(--color-border-secondary);
-    text-align: center;
-    border-top: 0;
-    border-bottom: 0;
-    background: var(--color-background-primary);
+  border: var(--border-m) dashed var(--color-border-secondary);
+  text-align: center;
+  border-top: 0;
+  border-bottom: 0;
+  background: var(--color-background-primary);
 }
 
 #report .ellipsis-icon {
-    display: flex;
-    flex-direction: column;
-    max-height: var(--spacing-xl);
+  display: flex;
+  flex-direction: column;
+  max-height: var(--spacing-xl);
 }
 
 #report
-    table:has([data-role="index-level-value"])
-    .elided-table-part
-    td:first-child
-    .ellipsis-icon
-    * {
-    align-self: flex-end;
+  table:has([data-role="index-level-value"])
+  .elided-table-part
+  td:first-child
+  .ellipsis-icon
+  * {
+  align-self: flex-end;
 }
 
 .table-bar-wrapper {
-    min-height: calc(var(--line-height) * 1em + 2 * var(--spacing-m));
-    display: flex;
-    flex-direction: column;
-    justify-content: center;
-    margin-bottom: var(--spacing-s);
+  min-height: calc(var(--line-height) * 1em + 2 * var(--spacing-m));
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  margin-bottom: var(--spacing-s);
 }
 
 /* Information below the table: dimensions and keyboard shortcuts */
 
 .table-footer {
-    display: flex;
-    justify-content: flex-start;
-    column-gap: var(--spacing-xxl);
-    padding-top: var(--spacing-s);
+  display: flex;
+  justify-content: flex-start;
+  column-gap: var(--spacing-xxl);
+  padding-top: var(--spacing-s);
 }
 
 .table-footer > * {
-    flex-shrink: 1;
+  flex-shrink: 1;
 }
 
 .keyboard-hints {
-    display: none;
-    flex-basis: max(0px, min(50%, calc((50% - 25ch) * 99999)));
-    min-width: 0px;
-    height: 2em;
-    margin-bottom: calc(-1 * var(--spacing-m));
-    margin-right: var(--spacing-s);
-    overflow: hidden;
-    flex-shrink: 10000;
+  display: none;
+  flex-basis: max(0px, min(50%, calc((50% - 25ch) * 99999)));
+  min-width: 0px;
+  height: 2em;
+  margin-bottom: calc(-1 * var(--spacing-m));
+  margin-right: var(--spacing-s);
+  overflow: hidden;
+  flex-shrink: 10000;
+}
+/*
+To make this computation binary, our two options are:
+- 40ch >= 50% -> flex-basis: 0 (no display of the buttons)
+- 40ch < 50% -> flex-basis: 50% (display of the buttons)
+
+The *99999 makes it impossible to be in the intermediate regime where 0px < 50% - 40ch < 50%
+*/
+.keyboard-hints-when-subsampling {
+  flex-basis: max(0px, min(50%, calc((50% - 40ch) * 99999)));
 }
 
 .keyboard-hints > div {
-    display: flex;
-    flex-wrap: wrap;
-    row-gap: 2em;
+  display: flex;
+  flex-wrap: wrap;
+  row-gap: 2em;
 }
 
 :has(.table-with-selectable-cells:focus-within)
-    ~ .table-footer
-    .keyboard-hints {
-    display: block;
+  ~ .table-footer
+  .keyboard-hints {
+  display: block;
 }
 
 .keyboard-key {
-    font-size: var(--font-size-xs);
-    font-family: var(--font-family-monospace);
-    padding: 0 var(--spacing-m);
-    line-height: 1.8;
-    color: var(--color-text-secondary);
-    border: var(--border-s) solid var(--color-border-secondary);
-    border-bottom: var(--border-s) solid var(--color-border-secondary);
-    box-shadow: inset 0 calc(-1 * var(--border-s)) 0
-        var(--color-shadow-keyboard);
-    border-radius: 0.2em;
-    min-width: 1em;
-    text-align: center;
-    margin: var(--spacing-xs);
-    display: inline-block;
-    text-wrap: nowrap;
-    flex-shrink: 0;
+  font-size: var(--font-size-xs);
+  font-family: var(--font-family-monospace);
+  padding: 0 var(--spacing-m);
+  line-height: 1.8;
+  color: var(--color-text-secondary);
+  border: var(--border-s) solid var(--color-border-secondary);
+  border-bottom: var(--border-s) solid var(--color-border-secondary);
+  box-shadow: inset 0 calc(-1 * var(--border-s)) 0 var(--color-shadow-keyboard);
+  border-radius: 0.2em;
+  min-width: 1em;
+  text-align: center;
+  margin: var(--spacing-xs);
+  display: inline-block;
+  text-wrap: nowrap;
+  flex-shrink: 0;
 }
diff --git a/skrub/_reporting/_data/templates/dataframe-sample.html b/skrub/_reporting/_data/templates/dataframe-sample.html
index b36d62a..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 -%}
@@ -90,7 +90,7 @@ data-spans__{{ i }}__{{ j }}
          (subsampled from more rows)
         {% endif %}
     </div>
-    <div class="keyboard-hints">
+    <div class="{{ 'keyboard-hints-when-subsampling' if 'is_subsampled' in summary }} keyboard-hints">
         <div>
             {{ kbd("Ctrl-C") }}
             {{ kbd("←") }}
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/_string_encoder.py b/skrub/_string_encoder.py
index 4ac1db9..0e7873b 100644
--- a/skrub/_string_encoder.py
+++ b/skrub/_string_encoder.py
@@ -10,6 +10,7 @@ from sklearn.pipeline import Pipeline
 
 from . import _dataframe as sbd
 from ._on_each_column 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..13dcb39 100644
--- a/skrub/_table_vectorizer.py
+++ b/skrub/_table_vectorizer.py
@@ -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/_text_encoder.py b/skrub/_text_encoder.py
index b07fd71..9647b94 100644
--- a/skrub/_text_encoder.py
+++ b/skrub/_text_encoder.py
@@ -10,6 +10,7 @@ from sklearn.utils.validation import check_is_fitted
 
 from . import _dataframe as sbd
 from ._on_each_column 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_datetime.py b/skrub/_to_datetime.py
index 56f5094..10d77ee 100644
--- a/skrub/_to_datetime.py
+++ b/skrub/_to_datetime.py
@@ -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)
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/tests/test_config.py b/skrub/tests/test_config.py
index f2f11e6..fb25018 100644
--- a/skrub/tests/test_config.py
+++ b/skrub/tests/test_config.py
@@ -18,6 +18,7 @@ def test_config_context():
         "max_association_columns": 30,
         "subsampling_seed": 0,
         "enable_subsampling": "default",
+        "float_precision": 3,
     }
 
     # Not using as a context manager affects nothing
@@ -87,6 +88,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 +117,7 @@ def test_enable_subsampling():
         {"max_association_columns": "hello"},
         {"subsampling_seed": -1},
         {"enable_subsampling": "no"},
+        {"float_precision": -1},
     ],
 )
 def test_error(params):
diff --git a/skrub/tests/test_gap_encoder.py b/skrub/tests/test_gap_encoder.py
index 05a6a59..43fdc6a 100644
--- a/skrub/tests/test_gap_encoder.py
+++ b/skrub/tests/test_gap_encoder.py
@@ -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_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_string_encoder.py b/skrub/tests/test_string_encoder.py
index c85b2ef..f1a0e10 100644
--- a/skrub/tests/test_string_encoder.py
+++ b/skrub/tests/test_string_encoder.py
@@ -12,6 +12,7 @@ from sklearn.pipeline import Pipeline
 from skrub import StringEncoder, TableVectorizer
 from skrub import _dataframe as sbd
 from skrub._on_each_column import RejectColumn
+from skrub._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)]