Repository: skrub-data/skrub
Pull request #1468 (2025-06-24 14:05:38)

Fix some CSS in TableReport
Follow-up on #1418, although I highly doubt a single user would have ever noticed this...


Patch:
diff --git a/.gitignore b/.gitignore
index 33c6092..e8a9d42 100644
--- a/.gitignore
+++ b/.gitignore
@@ -68,6 +68,7 @@ doc/CONTRIBUTING.rst
 doc/sg_execution_times.rst
 .DS_Store
 doc/_templates/demo_table_report_generated.html
+doc/reference/*.rst
 
 # Pkl files for benchmarks
 benchmarks/*.pkl
diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst
index 7b8e1e2..88deeab 100644
--- a/CONTRIBUTING.rst
+++ b/CONTRIBUTING.rst
@@ -367,3 +367,17 @@ specific examples, you can use the following command with a regex pattern:
     make html EXAMPLES_PATTERN=your_regex_goes_here make html
 
 This is especially helpful when you're only modifying or checking a few examples.
+
+
+Editing the API reference documentation
+---------------------------------------
+
+To add a new entry to the :ref:`API reference documentation<api_ref>` or change its
+content, head to ``doc/api_reference.py``. This data is then used by ``doc/conf.py``
+to render templates located at ``doc/reference/*.rst.template``.
+
+|
+
+Note that **all public functions and classes must be documented in the API
+reference**, hence when adding a public function or class, a new entry must be
+added, as detailed just above.
diff --git a/doc/api_reference.py b/doc/api_reference.py
new file mode 100644
index 0000000..dc7e827
--- /dev/null
+++ b/doc/api_reference.py
@@ -0,0 +1,300 @@
+"""Configuration for the API reference documentation.
+
+This file controls the content and ordering of the API reference section of the
+documentation. Edit this file to edit the documentation.
+
+CONFIGURING API_REFERENCE
+=========================
+
+API_REFERENCE maps each module name to a dictionary that consists of the following
+components:
+
+title (required)
+    The section title. It can't be `None`.
+short_summary (required)
+    The text to be printed on the index page; it has nothing to do the API reference
+    page of each module.
+description (required, `None` if not needed)
+    The additional description for the module to be placed under the module
+    docstring, before the sections start.
+sections (required)
+    A list of sections, each of which consists of:
+    - description (optional): the optional additional description for the section,
+    - autosummary (required): an autosummary block, assuming current module is the
+      current module name.
+
+Essentially, the rendered page would look like the following:
+
+|---------------------------------------------------------------------------------|
+|     {{ module_title }}                                                          |
+|     ==================                                                          |
+|     {{ module_docstring }}                                                      |
+|     {{ description }}                                                           |
+|                                                                                 |
+|     {{ section_description_1 }}                                                 |
+|     {{ section_autosummary_1 }}                                                 |
+|                                                                                 |
+|     {{ section_description_2 }}                                                 |
+|     {{ section_autosummary_2 }}                                                 |
+|                                                                                 |
+|     More sections...                                                            |
+|---------------------------------------------------------------------------------|
+
+Hooks will be automatically generated for each module and each section. For a module,
+e.g., `skrub.selectors`, the hook would be `selectors_ref`; for a
+section, e.g., "Building a pipeline" under `skrub`, the hook would be
+`_skrub_ref-building-a-pipeline`. However, note that a better way is to refer using
+the :mod: directive, e.g., :mod:`skrub.selectors` for the module and
+:mod:`skrub.text` for the section.
+"""
+
+API_REFERENCE = {
+    "pipeline": {
+        "title": "Building a pipeline",
+        "short_summary": None,
+        "description": None,
+        "sections": [
+            {
+                "description": (
+                    "See :ref:`end_to_end_pipeline <end_to_end_pipeline>` for "
+                    "further details. For more flexibility and control to build "
+                    "pipelines, see the :ref:`skrub expressions <expressions_ref>`."
+                ),
+                "autosummary": [
+                    "tabular_learner",
+                    "TableVectorizer",
+                    "Cleaner",
+                    "SelectCols",
+                    "DropCols",
+                    "DropUninformative",
+                ],
+            }
+        ],
+    },
+    "encoders": {
+        "title": "Encoding a column",
+        "short_summary": None,
+        "description": None,
+        "sections": [
+            {
+                "description": "See :ref:`encoding <encoding>` for further details.",
+                "autosummary": [
+                    "StringEncoder",
+                    "TextEncoder",
+                    "MinHashEncoder",
+                    "GapEncoder",
+                    "SimilarityEncoder",
+                    "ToCategorical",
+                    "DatetimeEncoder",
+                    "ToDatetime",
+                    "to_datetime",
+                ],
+            }
+        ],
+    },
+    "reporting": {
+        "title": "Exploring a dataframe",
+        "short_summary": None,
+        "description": None,
+        "sections": [
+            {
+                "description": None,
+                "autosummary": [
+                    "TableReport",
+                    "patch_display",
+                    "unpatch_display",
+                    "column_associations",
+                ],
+            }
+        ],
+    },
+    "cleaning": {
+        "title": "Cleaning a dataframe",
+        "short_summary": None,
+        "description": None,
+        "sections": [
+            {
+                "description": None,
+                "autosummary": [
+                    "deduplicate",
+                ],
+            },
+        ],
+    },
+    "joining": {
+        "title": "Joining dataframes",
+        "short_summary": None,
+        "description": None,
+        "sections": [
+            {
+                "description": None,
+                "autosummary": [
+                    "Joiner",
+                    "AggJoiner",
+                    "MultiAggJoiner",
+                    "AggTarget",
+                    "InterpolationJoiner",
+                    "fuzzy_join",
+                ],
+            },
+        ],
+    },
+    "selectors": {
+        "title": "Selectors",
+        "short_summary": None,
+        "description": None,
+        "sections": [
+            {
+                "description": (
+                    "Contains method to select columns in a dataframe. "
+                    "See the :ref:`selectors <selectors>` section for further details."
+                ),
+                "autosummary": [
+                    "selectors.all",
+                    "selectors.any_date",
+                    "selectors.boolean",
+                    "selectors.cardinality_below",
+                    "selectors.categorical",
+                    "selectors.cols",
+                    "selectors.filter",
+                    "selectors.filter_names",
+                    "selectors.float",
+                    "selectors.glob",
+                    "selectors.has_nulls",
+                    "selectors.integer",
+                    "selectors.inv",
+                    "selectors.make_selector",
+                    "selectors.numeric",
+                    "selectors.regex",
+                    "selectors.select",
+                    "selectors.string",
+                ],
+            }
+        ],
+    },
+    "expressions": {
+        "title": "Expressions",
+        "short_summary": None,
+        "description": None,
+        "sections": [
+            {
+                "description": (
+                    "Generalizing the scikit-learn pipeline. "
+                    "See :ref:`skrub expression <skrub_pipeline>` for further details."
+                ),
+                "autosummary": ["var", "X", "y", "as_expr", "deferred"],
+                "template": "base.rst",
+            },
+            {
+                "description": "The expression object.",
+                "autosummary": ["Expr"],
+                "template": "expr_class.rst",
+            },
+            {
+                "description": (
+                    "Inline hyperparameters selection within your expressions."
+                ),
+                "autosummary": [
+                    "choose_bool",
+                    "choose_float",
+                    "choose_int",
+                    "choose_from",
+                    "optional",
+                ],
+                "template": "base.rst",
+            },
+            {
+                "description": "Evaluate your expressions.",
+                "autosummary": ["cross_validate", "eval_mode"],
+                "template": "base.rst",
+            },
+            {
+                "description": (
+                    "The ``skb`` accessor exposes all expressions methods and "
+                    "attributes."
+                ),
+                "autosummary": [
+                    "Expr.skb.apply",
+                    "Expr.skb.apply_func",
+                    "Expr.skb.clone",
+                    "Expr.skb.concat",
+                    "Expr.skb.cross_validate",
+                    "Expr.skb.describe_defaults",
+                    "Expr.skb.describe_param_grid",
+                    "Expr.skb.describe_steps",
+                    "Expr.skb.draw_graph",
+                    "Expr.skb.drop",
+                    "Expr.skb.eval",
+                    "Expr.skb.freeze_after_fit",
+                    "Expr.skb.full_report",
+                    "Expr.skb.get_data",
+                    "Expr.skb.get_pipeline",
+                    "Expr.skb.get_grid_search",
+                    "Expr.skb.get_randomized_search",
+                    "Expr.skb.if_else",
+                    "Expr.skb.iter_pipelines_grid",
+                    "Expr.skb.iter_pipelines_randomized",
+                    "Expr.skb.mark_as_X",
+                    "Expr.skb.mark_as_y",
+                    "Expr.skb.match",
+                    "Expr.skb.preview",
+                    "Expr.skb.select",
+                    "Expr.skb.set_description",
+                    "Expr.skb.set_name",
+                    "Expr.skb.subsample",
+                    "Expr.skb.train_test_split",
+                ],
+                "template": "autosummary/accessor_method.rst",
+            },
+            {
+                "description": "Accessor attributes.",
+                "autosummary": [
+                    "Expr.skb.description",
+                    "Expr.skb.is_X",
+                    "Expr.skb.is_y",
+                    "Expr.skb.name",
+                    "Expr.skb.applied_estimator",
+                ],
+                "template": "autosummary/accessor_attribute.rst",
+            },
+            {
+                "description": "Objects generated by the expressions.",
+                "autosummary": [
+                    "SkrubPipeline",
+                    "ParamSearch",
+                ],
+                "template": "base.rst",
+            },
+        ],
+    },
+    "datasets": {
+        "title": "Datasets",
+        "short_summary": None,
+        "description": None,
+        "sections": [
+            {
+                "description": "Downloading a dataset.",
+                "autosummary": [
+                    "datasets.fetch_bike_sharing",
+                    "datasets.fetch_country_happiness",
+                    "datasets.fetch_credit_fraud",
+                    "datasets.fetch_drug_directory",
+                    "datasets.fetch_employee_salaries",
+                    "datasets.fetch_flight_delays",
+                    "datasets.fetch_ken_embeddings",
+                    "datasets.fetch_ken_table_aliases",
+                    "datasets.fetch_ken_types",
+                    "datasets.fetch_medical_charge",
+                    "datasets.fetch_midwest_survey",
+                    "datasets.fetch_movielens",
+                    "datasets.fetch_open_payments",
+                    "datasets.fetch_toxicity",
+                    "datasets.fetch_traffic_violations",
+                    "datasets.fetch_videogame_sales",
+                    "datasets.get_data_dir",
+                    "datasets.make_deduplication_data",
+                ],
+            }
+        ],
+    },
+}
diff --git a/doc/conf.py b/doc/conf.py
index bd64916..5887b3c 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -22,6 +22,9 @@ import shutil
 import sys
 import warnings
 from datetime import datetime
+from pathlib import Path
+
+import jinja2
 
 # Generate the table report html file for the homepage
 sys.path.append(os.path.relpath("."))
@@ -74,6 +77,7 @@ extensions = [
     "sphinx_issues",
     "sphinx_copybutton",
     "sphinx_gallery.gen_gallery",
+    "autoshortsummary",
 ]
 
 try:
@@ -580,3 +584,36 @@ copybutton_prompt_text = r">>> |\.\.\. |\$ "
 copybutton_prompt_is_regexp = True
 
 create_expression_report()
+
+# -- Convert .rst.template files to .rst ---------------------------------------
+
+from api_reference import API_REFERENCE
+
+rst_templates = [
+    (
+        "reference/index",
+        "reference/index",
+        {
+            "API_REFERENCE": list(API_REFERENCE.items()),
+        },
+    )
+]
+
+# Convert each module API reference page
+for module, module_info in API_REFERENCE.items():
+    rst_templates.append(
+        (
+            "reference/module",
+            f"reference/{module}",
+            {"module": module, "module_info": API_REFERENCE[module]},
+        )
+    )
+
+for rst_template_name, rst_target_name, kwargs in rst_templates:
+    # Read the corresponding template file into jinja2
+    r_path = Path(".") / f"{rst_template_name}.rst.template"
+    t = jinja2.Template(r_path.read_text(encoding="utf-8"))
+
+    # Render the template and write to the target
+    w_path = Path(".") / f"{rst_target_name}.rst"
+    w_path.write_text(t.render(**kwargs), encoding="utf-8")
diff --git a/doc/documentation.rst b/doc/documentation.rst
index 70246f2..60c60da 100644
--- a/doc/documentation.rst
+++ b/doc/documentation.rst
@@ -41,6 +41,6 @@ essential to prevent data leakage and ensure generalization.
    encoding
    skrub_pipeline
    skrub_pipeline_validation
-   selectors
+   skrub_selectors
    assembling
    cleaning
diff --git a/doc/encoding.rst b/doc/encoding.rst
index 1c2fd77..7eac312 100644
--- a/doc/encoding.rst
+++ b/doc/encoding.rst
@@ -22,7 +22,8 @@ work titles, city names etc.) and free-flowing text (reviews, comments etc.),
 while being very efficient and quick to fit.
 
 :class:`GapEncoder` provides better performance on dirty categories, while
-:class:`TextEncoder` works better on free-flowing text or when external context helps. However, both encoders
+:class:`TextEncoder` works better on free-flowing text or when external context helps.
+However, both encoders
 are much slower to execute, and in the case of ``TextEncoder``, additional
 dependencies are needed.
 
@@ -139,6 +140,16 @@ like any other pre-trained model. For more information, see the
 Encoding dates
 ===============
 
-The :class:`DatetimeEncoder` encodes date and time: it represent them as
-time in seconds since a fixed date, but also added features useful to
-capture regularities: week of the day, month of the year...
+
+The :meth:`to_datetime()` function and the :class:`ToDatetime` transformer parse
+common datetime formats and convert columns from string to datetime; additionally,
+if you are working with a specific time format, both objects accept a custom
+``format`` to use when converting.
+
+Feature engineering of datetime features can be done with the :class:`DatetimeEncoder`,
+which converts datetime columns into separate columns that contain each part of
+the datetime (year, month, day etc.), as well as additional simple features like
+the number of seconds from epoch and the day of the week. Periodic features are
+also supported, and can be added by setting the ``periodic_encoding`` variable
+to either ``circular`` (for trigonometric features), or ``spline`` (for spline
+features).
diff --git a/doc/reference/index.rst b/doc/reference/index.rst
deleted file mode 100644
index 8d16daf..0000000
--- a/doc/reference/index.rst
+++ /dev/null
@@ -1,301 +0,0 @@
-.. currentmodule:: skrub
-
-###
-API
-###
-
-This page lists all available functions and classes of `skrub`.
-
-
-.. _joining_dataframes_ref:
-
-Joining dataframes
-==================
-
-.. autosummary::
-   :toctree: generated/
-   :template: base.rst
-   :nosignatures:
-
-   Joiner
-   AggJoiner
-   MultiAggJoiner
-   AggTarget
-   InterpolationJoiner
-
-.. autosummary::
-   :toctree: generated/
-   :template: base.rst
-   :nosignatures:
-
-   fuzzy_join
-
-
-.. _encoding_a_column_ref:
-
-Encoding a column
-=================
-
-.. autosummary::
-   :toctree: generated/
-   :template: base.rst
-   :nosignatures:
-
-   GapEncoder
-   MinHashEncoder
-   SimilarityEncoder
-   DatetimeEncoder
-   ToCategorical
-   ToDatetime
-   StringEncoder
-
-.. autosummary::
-   :toctree: generated/
-   :template: base.rst
-   :nosignatures:
-
-   to_datetime
-
-Deep Learning
--------------
-
-These encoders require installing additional dependencies around torch.
-See the "deep learning dependencies" section in the :ref:`installation_instructions`
-guide for more details.
-
-.. autosummary::
-   :toctree: generated/
-   :template: base.rst
-   :nosignatures:
-
-   TextEncoder
-
-
-.. _building_a_pipeline_ref:
-
-Building a pipeline
-===================
-
-.. autosummary::
-   :toctree: generated/
-   :template: base.rst
-   :nosignatures:
-
-   TableVectorizer
-   Cleaner
-   SelectCols
-   DropCols
-   DropUninformative
-
-.. autosummary::
-   :toctree: generated/
-   :template: base.rst
-   :nosignatures:
-
-   tabular_learner
-
-
-.. _expressions_ref:
-
-Skrub Expressions
-=================
-
-The ``tabular_learner`` provides a pre-defined, default pipeline for datasets that contain a simple table.
-For more control or in order to build pipelines for more datasets, use the skrub expressions.
-
-
-.. autosummary::
-   :toctree: generated/
-   :template: base.rst
-   :nosignatures:
-
-   as_expr
-   choose_bool
-   choose_float
-   choose_from
-   choose_int
-   cross_validate
-   deferred
-   eval_mode
-   optional
-   train_test_split
-   var
-   X
-   y
-
-.. autosummary::
-   :toctree: generated/
-   :template: expr_class.rst
-   :nosignatures:
-
-   Expr
-
-.. autosummary::
-   :toctree: generated/
-   :template: autosummary/accessor_method.rst
-   :nosignatures:
-
-   Expr.skb.apply
-   Expr.skb.apply_func
-   Expr.skb.clone
-   Expr.skb.concat
-   Expr.skb.cross_validate
-   Expr.skb.describe_defaults
-   Expr.skb.describe_param_grid
-   Expr.skb.describe_steps
-   Expr.skb.draw_graph
-   Expr.skb.drop
-   Expr.skb.eval
-   Expr.skb.freeze_after_fit
-   Expr.skb.full_report
-   Expr.skb.get_data
-   Expr.skb.get_pipeline
-   Expr.skb.get_grid_search
-   Expr.skb.get_randomized_search
-   Expr.skb.if_else
-   Expr.skb.iter_pipelines_grid
-   Expr.skb.iter_pipelines_randomized
-   Expr.skb.mark_as_X
-   Expr.skb.mark_as_y
-   Expr.skb.match
-   Expr.skb.preview
-   Expr.skb.select
-   Expr.skb.set_description
-   Expr.skb.set_name
-   Expr.skb.subsample
-   Expr.skb.train_test_split
-
-.. autosummary::
-   :toctree: generated/
-   :template: autosummary/accessor_attribute.rst
-   :nosignatures:
-
-   Expr.skb.description
-   Expr.skb.is_X
-   Expr.skb.is_y
-   Expr.skb.name
-   Expr.skb.applied_estimator
-
-.. autosummary::
-   :toctree: generated/
-   :template: base.rst
-   :nosignatures:
-
-   SkrubPipeline
-   ParamSearch
-
-.. _selectors_ref:
-
-Selecting columns in a DataFrame
-================================
-
-The srkub selectors provide a flexible way to specify the columns on which a
-transformation should be applied. They are meant to be used for the ``cols``
-argument of :meth:`Expr.skb.apply`, :meth:`Expr.skb.select`,
-:meth:`Expr.skb.drop`, :class:`SelectCols` or :class:`DropCols`.
-
-.. autosummary::
-   :toctree: generated/
-   :template: base.rst
-   :nosignatures:
-
-   selectors.all
-   selectors.any_date
-   selectors.boolean
-   selectors.cardinality_below
-   selectors.categorical
-   selectors.cols
-   selectors.Filter
-   selectors.filter
-   selectors.filter_names
-   selectors.float
-   selectors.glob
-   selectors.has_nulls
-   selectors.integer
-   selectors.inv
-   selectors.make_selector
-   selectors.NameFilter
-   selectors.numeric
-   selectors.regex
-   selectors.select
-   selectors.Selector
-   selectors.string
-
-.. _generating_a_report_ref:
-
-Generating an HTML report
-=========================
-
-.. autosummary::
-   :toctree: generated/
-   :template: base.rst
-   :nosignatures:
-
-   TableReport
-
-.. autosummary::
-   :toctree: generated/
-   :template: base.rst
-   :nosignatures:
-
-   patch_display
-   unpatch_display
-   column_associations
-
-.. _configuration:
-
-Configuration
-=============
-
-.. autosummary::
-   :toctree: generated/
-   :template: base.rst
-   :nosignatures:
-
-   get_config
-   set_config
-   config_context
-
-.. _cleaning_a_dataframe_ref:
-
-Cleaning a dataframe
-====================
-
-.. autosummary::
-   :toctree: generated/
-   :template: base.rst
-   :nosignatures:
-
-   deduplicate
-
-
-.. _downloading_a_dataset_ref:
-
-.. currentmodule:: skrub.datasets
-
-Downloading a dataset
-=====================
-
-.. autosummary::
-   :toctree: generated/
-   :template: base.rst
-   :nosignatures:
-
-   fetch_bike_sharing
-   fetch_country_happiness
-   fetch_credit_fraud
-   fetch_drug_directory
-   fetch_employee_salaries
-   fetch_flight_delays
-   fetch_ken_embeddings
-   fetch_ken_table_aliases
-   fetch_ken_types
-   fetch_medical_charge
-   fetch_midwest_survey
-   fetch_movielens
-   fetch_open_payments
-   fetch_toxicity
-   fetch_traffic_violations
-   fetch_videogame_sales
-   get_data_dir
-   make_deduplication_data
diff --git a/doc/reference/index.rst.template b/doc/reference/index.rst.template
new file mode 100644
index 0000000..22c0220
--- /dev/null
+++ b/doc/reference/index.rst.template
@@ -0,0 +1,48 @@
+:html_theme.sidebar_secondary.remove:
+
+.. _api_ref:
+
+=============
+API Reference
+=============
+
+This is the class and function reference of skrub. Please refer to the
+:ref:`full user guide <user_guide>` for further details, as the raw specifications of
+classes and functions may not be enough to give full guidelines on their use.
+
+.. toctree::
+  :maxdepth: 1
+  :hidden:
+
+{% for module, _ in API_REFERENCE %}
+  {{ module }} <{{ module }}>
+{%- endfor %}
+{%- if DEPRECATED_API_REFERENCE %}
+  deprecated
+{%- endif %}
+
+.. currentmodule:: skrub
+
+{% for _, module_info in API_REFERENCE %}
+
+{{ module_info["title"] }}
+{{ "-" * module_info["title"]|length }}
+
+{% for section in module_info["sections"] %}
+
+{% if section["template"] %}
+{% set template = section["template"] %}
+{% else %}
+{% set template = "base.rst" %}
+{% endif %}
+
+.. autosummary::
+  :nosignatures:
+  :template: {{ template }}
+
+{% for obj in section["autosummary"] %}
+  {{ obj }}
+
+{% endfor %}
+{% endfor %}
+{% endfor %}
diff --git a/doc/reference/module.rst.template b/doc/reference/module.rst.template
new file mode 100644
index 0000000..b3b3213
--- /dev/null
+++ b/doc/reference/module.rst.template
@@ -0,0 +1,56 @@
+:html_theme.sidebar_secondary.remove:
+
+{% if module == "skrub" -%}
+{%- set module_hook = "skrub" -%}
+{%- elif module.startswith("skrub.") -%}
+{%- set module_hook = module[6:] -%}
+{%- else -%}
+{%- set module_hook = None -%}
+{%- endif -%}
+
+{% if module_hook %}
+.. _{{ module_hook }}_ref:
+{% endif %}
+
+.. title:: {{ module }}
+
+{% if not module.startswith("skrub.") -%}
+{% set module = "skrub" %}
+.. currentmodule:: {{ module }}
+{%- else -%}
+.. automodule:: {{ module }}
+{%- endif -%}
+
+{% if module_info["description"] %}
+{{ module_info["description"] }}
+{% endif %}
+
+{% if module_info["title"] %}
+{{ module_info["title"] }}
+{{ "-" * module_info["title"]|length }}
+{% endif %}
+
+{% for section in module_info["sections"] %}
+{% if section["title"] and module_hook %}
+.. _{{ module_hook }}_ref-{{ section["title"]|lower|replace(" ", "-") }}:
+{% endif %}
+
+{% if section["description"] %}
+{{ section["description"] }}
+{% endif %}
+
+{% if section["template"] %}
+{% set template = section["template"] %}
+{% else %}
+{% set template = "base.rst" %}
+{% endif %}
+
+.. autosummary::
+  :nosignatures:
+  :toctree: generated/
+  :template: {{ template }}
+
+{% for obj in section["autosummary"] %}
+  {{ obj }}
+{%- endfor %}
+{% endfor %}
diff --git a/doc/sphinxext/autoshortsummary.py b/doc/sphinxext/autoshortsummary.py
new file mode 100644
index 0000000..8f50b07
--- /dev/null
+++ b/doc/sphinxext/autoshortsummary.py
@@ -0,0 +1,52 @@
+# Adapted from scikit-learn
+from sphinx.ext.autodoc import ModuleLevelDocumenter
+
+
+class ShortSummaryDocumenter(ModuleLevelDocumenter):
+    """An autodocumenter that only renders the short summary of the object."""
+
+    # Defines the usage: .. autoshortsummary:: {{ object }}
+    objtype = "shortsummary"
+
+    # Disable content indentation
+    content_indent = ""
+
+    # Avoid being selected as the default documenter for some objects, because we are
+    # returning `can_document_member` as True for all objects
+    priority = -99
+
+    @classmethod
+    def can_document_member(cls, member, membername, isattr, parent):
+        """Allow documenting any object."""
+        return True
+
+    def get_object_members(self, want_all):
+        """Document no members."""
+        return (False, [])
+
+    def add_directive_header(self, sig):
+        """Override default behavior to add no directive header or options."""
+        pass
+
+    def add_content(self, more_content):
+        """Override default behavior to add only the first line of the docstring.
+        Modified based on the part of processing docstrings in the original
+        implementation of this method.
+        https://github.com/sphinx-doc/sphinx/blob/faa33a53a389f6f8bc1f6ae97d6015fa92393c4a/sphinx/ext/autodoc/__init__.py#L609-L622
+        """
+        sourcename = self.get_sourcename()
+        docstrings = self.get_doc()
+
+        if docstrings is not None:
+            if not docstrings:
+                docstrings.append([])
+            # Get the first non-empty line of the processed docstring; this could lead
+            # to unexpected results if the object does not have a short summary line.
+            short_summary = next(
+                (s for s in self.process_doc(docstrings) if s), "<no summary>"
+            )
+            self.add_line(short_summary, sourcename, 0)
+
+
+def setup(app):
+    app.add_autodocumenter(ShortSummaryDocumenter)
diff --git a/skrub/_agg_joiner.py b/skrub/_agg_joiner.py
index 67b8b8d..3fd55f1 100644
--- a/skrub/_agg_joiner.py
+++ b/skrub/_agg_joiner.py
@@ -16,6 +16,7 @@ from sklearn.utils.validation import check_is_fitted
 from skrub import _dataframe as sbd
 from skrub import _join_utils, _utils
 from skrub import selectors as s
+from skrub._dataframe._common import _raise as _sbd_raise
 from skrub._dispatch import dispatch
 
 from ._check_input import CheckInputDataFrame
@@ -90,7 +91,7 @@ def aggregate(table, key, cols_to_agg, operations, suffix):
 
 @dispatch
 def perform_groupby(table, key, cols_to_agg, operations):
-    raise NotImplementedError()
+    raise _sbd_raise(table, kind="DataFrame")
 
 
 @perform_groupby.specialize("pandas", argument_type="DataFrame")
diff --git a/skrub/_check_input.py b/skrub/_check_input.py
index 77c23cb..57e34fc 100644
--- a/skrub/_check_input.py
+++ b/skrub/_check_input.py
@@ -1,6 +1,7 @@
 import warnings
 
 import numpy as np
+import pandas as pd
 from sklearn.base import BaseEstimator, TransformerMixin
 from sklearn.utils.validation import check_is_fitted
 
@@ -51,8 +52,6 @@ def _check_not_pandas_sparse(df):
 
 @_check_not_pandas_sparse.specialize("pandas", argument_type="DataFrame")
 def _check_not_pandas_sparse_pandas(df):
-    import pandas as pd
-
     sparse_cols = [
         col for col in df.columns if isinstance(df[col].dtype, pd.SparseDtype)
     ]
diff --git a/skrub/_clean_categories.py b/skrub/_clean_categories.py
index 27d4689..49eba79 100644
--- a/skrub/_clean_categories.py
+++ b/skrub/_clean_categories.py
@@ -1,6 +1,7 @@
 import numpy as np
 
 from . import _dataframe as sbd
+from ._dataframe._common import _raise as _sbd_raise
 from ._dispatch import dispatch
 from ._on_each_column import RejectColumn, SingleColumnTransformer
 
@@ -9,7 +10,7 @@ __all__ = ["CleanCategories"]
 
 @dispatch
 def _with_string_categories(col):
-    raise NotImplementedError()
+    raise _sbd_raise(col, kind="Series")
 
 
 @_with_string_categories.specialize("pandas", argument_type="Column")
diff --git a/skrub/_clean_null_strings.py b/skrub/_clean_null_strings.py
index d082d12..83be582 100644
--- a/skrub/_clean_null_strings.py
+++ b/skrub/_clean_null_strings.py
@@ -1,4 +1,5 @@
 from . import _dataframe as sbd
+from ._dataframe._common import _raise as _sbd_raise
 from ._dispatch import dispatch
 from ._on_each_column import RejectColumn, SingleColumnTransformer
 
@@ -31,7 +32,7 @@ STR_NA_VALUES = [
 
 @dispatch
 def _trim_whitespace_only(col):
-    raise NotImplementedError()
+    raise _sbd_raise(col, kind="Series")
 
 
 @_trim_whitespace_only.specialize("pandas", argument_type="Column")
diff --git a/skrub/_column_associations.py b/skrub/_column_associations.py
index e934432..b37519d 100644
--- a/skrub/_column_associations.py
+++ b/skrub/_column_associations.py
@@ -17,8 +17,22 @@ def column_associations(df):
     """Get measures of statistical associations between all pairs of columns.
 
     Reported metrics include Cramer's V statistic and Pearson's Correlation
-    Coefficient. More may be added in the future.
+    Coefficient. The result is returned as a dataframe that contains the column
+    name and idx for the left and right table and both associations; results
+    are sorted in descending order by Cramer's V association.
 
+    Parameters
+    ----------
+    df : dataframe
+        The dataframe whose columns will be compared to each other.
+
+    Returns
+    -------
+    dataframe
+        The computed associations.
+
+    Notes
+    -----
     The result is returned as a dataframe with columns:
 
     ``['left_column_name', 'left_column_idx', 'right_column_name',
@@ -34,29 +48,15 @@ def column_associations(df):
     separate category, ie a separate row in the contingency table. Thus,
     associations between the values of 2 columns or between their missingness
     patterns may be captured.
-
-    To compute the Pearson's Correlation Coefficient, only numeric columns are
-    considered. The correlation is computed using the Pearson method used in
-    pandas or polars, depending on the dataframe. In both cases, lines containing NaNs
-    are dropped.
-
-    Parameters
-    ----------
-    df : dataframe
-        The dataframe whose columns will be compared to each other.
-
-    Returns
-    -------
-    dataframe
-        The computed associations.
-
-    Notes
-    -----
     Cramér's V is a measure of association between two nominal variables,
     giving a value between 0 and +1 (inclusive).
 
     * `Cramer's V <https://en.wikipedia.org/wiki/Cramér%27s_V>`_
 
+    To compute the Pearson's Correlation Coefficient, only numeric columns are
+    considered. The correlation is computed using the Pearson method used in
+    pandas or polars, depending on the dataframe. In both cases, lines containing NaNs
+    are dropped.
     Pearson's Correlation Coefficient is a measure of the linear correlation
     between two variables, giving a value between -1 and +1 (inclusive).
 
diff --git a/skrub/_dataframe/_common.py b/skrub/_dataframe/_common.py
index 540a83a..9e527b3 100644
--- a/skrub/_dataframe/_common.py
+++ b/skrub/_dataframe/_common.py
@@ -117,10 +117,17 @@ pandas_version = parse_version(parse_version(pd.__version__).base_version)
 #
 
 
+def _raise(obj, kind="object"):
+    raise TypeError(
+        "Operation not supported on this object. Expecting a Pandas or Polars "
+        f"{kind}, but got an object of type {type(obj)}."
+    )
+
+
 @dispatch
 def dataframe_module_name(obj):
     """Return the dataframe module this object belongs to: 'pandas' or 'polars'."""
-    raise NotImplementedError()
+    raise _raise(obj)
 
 
 @dataframe_module_name.specialize("pandas")
@@ -194,7 +201,7 @@ def _is_column_polars(obj):
 
 @dispatch
 def to_list(col):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @to_list.specialize("pandas", argument_type="Column")
@@ -210,7 +217,7 @@ def _to_list_polars(col):
 
 @dispatch
 def to_numpy(col):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @to_numpy.specialize("pandas", argument_type="Column")
@@ -227,7 +234,7 @@ def _to_numpy_polars_column(col):
 
 @dispatch
 def to_pandas(obj):
-    raise NotImplementedError()
+    raise _raise(obj)
 
 
 @to_pandas.specialize("pandas")
@@ -251,7 +258,7 @@ def make_dataframe_like(obj, data):
     i.e. to determine if the resulting dataframe should be a pandas or polars
     dataframe.
     """
-    raise NotImplementedError()
+    raise _raise(obj)
 
 
 @make_dataframe_like.specialize("pandas")
@@ -268,7 +275,7 @@ def _make_dataframe_like_polars(obj, data):
 
 @dispatch
 def make_column_like(obj, values, name):
-    raise NotImplementedError()
+    raise _raise(obj)
 
 
 @make_column_like.specialize("pandas")
@@ -283,7 +290,7 @@ def _make_column_like_polars(obj, values, name):
 
 @dispatch
 def null_value_for(obj):
-    raise NotImplementedError()
+    raise _raise(obj)
 
 
 @null_value_for.specialize("pandas")
@@ -300,7 +307,7 @@ def _null_value_for_polars(obj):
 
 @dispatch
 def all_null_like(col, length=None, dtype=None, name=None):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @all_null_like.specialize("pandas", argument_type="Column")
@@ -357,7 +364,9 @@ def _check_same_type(objects):
 
 @dispatch
 def concat(*dataframes, axis=0):
-    raise NotImplementedError()
+    # This is accessed only when the first element of *dataframes is neither
+    # a pandas or polars valid type.
+    raise _raise(dataframes[0])
 
 
 @concat.specialize("pandas", argument_type="DataFrame")
@@ -419,7 +428,7 @@ def to_column_list(obj):
 
 @dispatch
 def col(df, col_name):
-    raise NotImplementedError()
+    raise _raise(df, kind="DataFrame")
 
 
 @col.specialize("pandas", argument_type="DataFrame")
@@ -434,7 +443,7 @@ def _col_polars(df, col_name):
 
 @dispatch
 def col_by_idx(df, col_idx):
-    raise NotImplementedError()
+    raise _raise(df, kind="DataFrame")
 
 
 @col_by_idx.specialize("pandas", argument_type="DataFrame")
@@ -465,7 +474,7 @@ def _collect_polars_lazyframe(df):
 
 @dispatch
 def shape(obj):
-    raise NotImplementedError()
+    raise _raise(obj)
 
 
 @shape.specialize("pandas")
@@ -481,7 +490,7 @@ def _shape_polars(obj):
 @dispatch
 def to_frame(col):
     """Convert a single Column to a DataFrame."""
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @to_frame.specialize("pandas", argument_type="Column")
@@ -496,7 +505,7 @@ def _to_frame_polars(col):
 
 @dispatch
 def name(col):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @name.specialize("pandas", argument_type="Column")
@@ -511,7 +520,7 @@ def _name_polars(col):
 
 @dispatch
 def column_names(df):
-    raise NotImplementedError()
+    raise _raise(df, kind="DataFrame")
 
 
 @column_names.specialize("pandas", argument_type="DataFrame")
@@ -526,7 +535,7 @@ def _column_names_polars(df):
 
 @dispatch
 def rename(col, new_name):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @rename.specialize("pandas", argument_type="Column")
@@ -541,7 +550,7 @@ def _rename_polars(col, new_name):
 
 @dispatch
 def set_column_names(df, new_col_names):
-    raise NotImplementedError()
+    raise _raise(df, kind="DataFrame")
 
 
 @set_column_names.specialize("pandas", argument_type="DataFrame")
@@ -611,7 +620,7 @@ def _index_pandas(obj):
 
 @dispatch
 def dtype(col):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @dtype.specialize("pandas", argument_type="Column")
@@ -626,7 +635,7 @@ def _dtype_polars(col):
 
 @dispatch
 def dtypes(df):
-    raise NotImplementedError()
+    raise _raise(df, kind="DataFrame")
 
 
 @dtypes.specialize("pandas", argument_type="DataFrame")
@@ -641,7 +650,7 @@ def _dtypes_polars(df):
 
 @dispatch
 def cast(col, dtype):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @cast.specialize("pandas", argument_type="Column")
@@ -660,7 +669,7 @@ def _cast_polars(col, dtype):
 
 @dispatch
 def is_pandas_extension_dtype(obj):
-    raise NotImplementedError()
+    raise _raise(obj)
 
 
 @is_pandas_extension_dtype.specialize("pandas")
@@ -685,7 +694,7 @@ def _pandas_convert_dtypes_pandas(obj):
 
 @dispatch
 def is_bool(col):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @is_bool.specialize("pandas", argument_type="Column")
@@ -702,7 +711,7 @@ def _is_bool_polars(col):
 
 @dispatch
 def is_numeric(col):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @is_numeric.specialize("pandas", argument_type="Column")
@@ -720,7 +729,7 @@ def _is_numeric_polars(col):
 
 @dispatch
 def is_integer(col):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @is_integer.specialize("pandas", argument_type="Column")
@@ -735,7 +744,7 @@ def _is_integer_polars(col):
 
 @dispatch
 def is_float(col):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @is_float.specialize("pandas", argument_type="Column")
@@ -750,7 +759,7 @@ def _is_float_polars(col):
 
 @dispatch
 def to_float32(col, strict=True):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @to_float32.specialize("pandas", argument_type="Column")
@@ -771,7 +780,7 @@ def _to_float32_polars(col, strict=True):
 
 @dispatch
 def is_string(col):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @is_string.specialize("pandas", argument_type="Column")
@@ -794,7 +803,7 @@ def _is_string_polars(col):
 
 @dispatch
 def to_string(col):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @to_string.specialize("pandas", argument_type="Column")
@@ -825,7 +834,7 @@ def _to_string_polars(col):
 
 @dispatch
 def is_object(col):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @is_object.specialize("pandas", argument_type="Column")
@@ -844,7 +853,7 @@ def is_pandas_object(col):
 
 @dispatch
 def is_any_date(col):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @is_any_date.specialize("pandas", argument_type="Column")
@@ -859,7 +868,7 @@ def _is_any_date_polars(col):
 
 @dispatch
 def to_datetime(col, format, strict=True):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @to_datetime.specialize("pandas", argument_type="Column")
@@ -886,7 +895,7 @@ def _to_datetime_polars(col, format, strict=True):
 
 @dispatch
 def is_duration(col):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @is_duration.specialize("pandas", argument_type="Column")
@@ -901,7 +910,7 @@ def _is_duration_polars(col):
 
 @dispatch
 def is_categorical(col):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @is_categorical.specialize("pandas", argument_type="Column")
@@ -916,7 +925,7 @@ def _is_categorical_polars(col):
 
 @dispatch
 def to_categorical(col):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @to_categorical.specialize("pandas", argument_type="Column")
@@ -934,7 +943,7 @@ def _to_categorical_polars(col):
 
 @dispatch
 def is_all_null(col):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @is_all_null.specialize("pandas", argument_type="Column")
@@ -962,7 +971,7 @@ def _is_all_null_polars(col):
 
 @dispatch
 def all(col):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @all.specialize("pandas", argument_type="Column")
@@ -977,7 +986,7 @@ def _all_polars(col):
 
 @dispatch
 def any(col):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @any.specialize("pandas", argument_type="Column")
@@ -992,7 +1001,7 @@ def _any_polars(col):
 
 @dispatch
 def sum(col):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @sum.specialize("pandas", argument_type="Column")
@@ -1007,7 +1016,7 @@ def _sum_polars_col(col):
 
 @dispatch
 def min(col):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @min.specialize("pandas", argument_type="Column")
@@ -1022,7 +1031,7 @@ def _min_polars_col(col):
 
 @dispatch
 def max(col):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @max.specialize("pandas", argument_type="Column")
@@ -1037,7 +1046,7 @@ def _max_polars_col(col):
 
 @dispatch
 def std(col):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @std.specialize("pandas", argument_type="Column")
@@ -1052,7 +1061,7 @@ def _std_polars_col(col):
 
 @dispatch
 def mean(col):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @mean.specialize("pandas", argument_type="Column")
@@ -1067,7 +1076,7 @@ def _mean_polars_col(col):
 
 @dispatch
 def pearson_corr(df):
-    raise NotImplementedError()
+    raise _raise(df, kind="DataFrame")
 
 
 @pearson_corr.specialize("pandas", argument_type="DataFrame")
@@ -1081,23 +1090,23 @@ def _pearson_corr_polars(df):
 
 
 @dispatch
-def value_counts(column):
-    raise NotImplementedError()
+def value_counts(col):
+    raise _raise(col, kind="Series")
 
 
 @value_counts.specialize("pandas", argument_type="Column")
-def _value_counts_pandas(column):
+def _value_counts_pandas(col):
     return (
-        column.value_counts(dropna=True)
+        col.value_counts(dropna=True)
         .reset_index()
         .set_axis(["value", "count"], axis="columns")
     )
 
 
 @value_counts.specialize("polars", argument_type="Column")
-def _value_counts_polars(column):
+def _value_counts_polars(col):
     return (
-        column.drop_nulls()
+        col.drop_nulls()
         .rename("value")
         .value_counts()
         .with_columns(pl.col("count").cast(pl.Int64))
@@ -1106,7 +1115,7 @@ def _value_counts_polars(column):
 
 @dispatch
 def sort(df, by, descending=False):
-    raise NotImplementedError()
+    raise _raise(df, kind="DataFrame")
 
 
 @sort.specialize("pandas", argument_type="DataFrame")
@@ -1122,23 +1131,23 @@ def _sort_polars_dataframe(df, by, descending=False):
 
 
 @dispatch
-def quantile(column, q, interpolation="nearest"):
-    raise NotImplementedError()
+def quantile(col, q, interpolation="nearest"):
+    raise _raise(col, kind="Series")
 
 
 @quantile.specialize("pandas", argument_type="Column")
-def _quantile_pandas_column(column, q, interpolation="nearest"):
-    return column.quantile(q, interpolation=interpolation)
+def _quantile_pandas_column(col, q, interpolation="nearest"):
+    return col.quantile(q, interpolation=interpolation)
 
 
 @quantile.specialize("polars", argument_type="Column")
-def _quantile_polars_column(column, q, interpolation="nearest"):
-    return _drop_nulls_polars(column).quantile(q, interpolation=interpolation)
+def _quantile_polars_column(col, q, interpolation="nearest"):
+    return _drop_nulls_polars(col).quantile(q, interpolation=interpolation)
 
 
 @dispatch
 def is_null(col):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @is_null.specialize("pandas", argument_type="Column")
@@ -1159,7 +1168,7 @@ def has_nulls(col):
 
 @dispatch
 def drop_nulls(col):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @drop_nulls.specialize("pandas", argument_type="Column")
@@ -1176,7 +1185,7 @@ def _drop_nulls_polars(col):
 
 @dispatch
 def fill_nulls(obj, value):
-    raise NotImplementedError()
+    raise _raise(obj)
 
 
 @fill_nulls.specialize("pandas")
@@ -1205,7 +1214,7 @@ def _fill_nulls_polars_dataframe(df, value):
 
 @dispatch
 def n_unique(col):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @n_unique.specialize("pandas", argument_type="Column")
@@ -1223,7 +1232,7 @@ def _n_unique_polars(col):
 
 @dispatch
 def unique(col):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @unique.specialize("pandas", argument_type="Column")
@@ -1238,7 +1247,7 @@ def _unique_polars(col):
 
 @dispatch
 def filter(obj, predicate):
-    raise NotImplementedError()
+    raise _raise(obj)
 
 
 @filter.specialize("pandas")
@@ -1253,7 +1262,7 @@ def _filter_polars(obj, predicate):
 
 @dispatch
 def where(col, mask, other):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @where.specialize("pandas", argument_type="Column")
@@ -1268,7 +1277,7 @@ def _where_polars(col, mask, other):
 
 @dispatch
 def where_row(obj, mask, other):
-    raise NotImplementedError()
+    raise _raise(obj)
 
 
 @where_row.specialize("pandas")
@@ -1285,7 +1294,7 @@ def _where_row_polars(obj, mask, other):
 
 @dispatch
 def sample(obj, n, seed=None):
-    raise NotImplementedError()
+    raise _raise(obj)
 
 
 @sample.specialize("pandas")
@@ -1300,7 +1309,7 @@ def _sample_polars(obj, n, seed=None):
 
 @dispatch
 def head(obj, n=5):
-    raise NotImplementedError()
+    raise _raise(obj)
 
 
 @head.specialize("pandas")
@@ -1315,7 +1324,7 @@ def _head_polars(obj, n=5):
 
 @dispatch
 def slice(obj, *start_stop):
-    raise NotImplementedError()
+    raise _raise(obj)
 
 
 @slice.specialize("pandas")
@@ -1331,7 +1340,7 @@ def _slice_polars(obj, *start_stop):
 
 @dispatch
 def replace(col, old, new):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @replace.specialize("pandas", argument_type="Column")
@@ -1352,7 +1361,7 @@ def with_columns(df, **new_cols):
 
 @dispatch
 def abs(col):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
 @abs.specialize("pandas", argument_type="Column")
@@ -1367,14 +1376,14 @@ def _abs_polars(col):
 
 @dispatch
 def total_seconds(col):
-    raise NotImplementedError()
+    raise _raise(col, kind="Series")
 
 
-@total_seconds.specialize("pandas")
+@total_seconds.specialize("pandas", argument_type="Column")
 def _total_seconds_pandas(col):
     return col.dt.total_seconds()
 
 
-@total_seconds.specialize("polars")
+@total_seconds.specialize("polars", argument_type="Column")
 def _total_seconds_polars(col):
     return col.dt.total_microseconds().cast(float) * 1e-6
diff --git a/skrub/_datetime_encoder.py b/skrub/_datetime_encoder.py
index 48d8768..d71f29f 100644
--- a/skrub/_datetime_encoder.py
+++ b/skrub/_datetime_encoder.py
@@ -5,11 +5,6 @@ import pandas as pd
 from sklearn.preprocessing import SplineTransformer
 from sklearn.utils.validation import check_is_fitted
 
-try:
-    import polars as pl
-except ImportError:
-    pass
-
 from . import _dataframe as sbd
 from ._dispatch import dispatch
 from ._on_each_column import RejectColumn, SingleColumnTransformer
@@ -44,7 +39,10 @@ _DEFAULT_ENCODING_SPLINES = {
 
 @dispatch
 def _is_date(col):
-    raise NotImplementedError()
+    # Avoid circular import
+    from skrub._dataframe._common import _raise
+
+    raise _raise(col, kind="Series")
 
 
 @_is_date.specialize("pandas", argument_type="Column")
@@ -60,7 +58,10 @@ def _is_date_polars(col):
 
 @dispatch
 def _get_dt_feature(col, feature):
-    raise NotImplementedError()
+    # Avoid circular import
+    from skrub._dataframe._common import _raise
+
+    raise _raise(col, kind="Series")
 
 
 @_get_dt_feature.specialize("pandas", argument_type="Column")
@@ -82,6 +83,8 @@ def _get_dt_feature_pandas(col, feature):
 
 @_get_dt_feature.specialize("polars", argument_type="Column")
 def _get_dt_feature_polars(col, feature):
+    import polars as pl
+
     if feature == "total_seconds":
         return (col.dt.timestamp(time_unit="ms") / 1000).cast(pl.Float32)
     if feature == "day_of_year":
diff --git a/skrub/_drop_uninformative.py b/skrub/_drop_uninformative.py
index 32b61ff..4921557 100644
--- a/skrub/_drop_uninformative.py
+++ b/skrub/_drop_uninformative.py
@@ -11,17 +11,8 @@ __all__ = ["DropUninformative"]
 class DropUninformative(SingleColumnTransformer):
     """Drop column if it is found to be uninformative according to various criteria.
 
-    A column is considered to be "uninformative" if one or more of the following
-    issues are found:
-
-    - The fraction of missing values is larger than a certain fraction (by default,
-      all values must be null for the column to be dropped).
-    - The column includes only one unique value (the column is constant). Missing
-      values are considered a separate value.
-    - The number of unique values in the column is equal to the length of the
-      column, i.e., all values are unique. This is only considered for non-numeric
-      columns. Missing values are considered a separate value. Note that this
-      may lead to dropping columns that contain free-flowing text.
+    Columns are considered "uninformative" if the fraction of missing values is larger
+    than a threshold, if they contain one unique value, or if all values are unique.
 
     Parameters
     ----------
@@ -38,6 +29,20 @@ class DropUninformative(SingleColumnTransformer):
         Drop columns with a fraction of missing values larger than threshold. If None,
         keep the column even if all its values are missing.
 
+    Notes
+    -----
+    A column is considered to be "uninformative" if one or more of the following
+    issues are found:
+
+    - The fraction of missing values is larger than a certain fraction (by default,
+      all values must be null for the column to be dropped).
+    - The column includes only one unique value (the column is constant). Missing
+      values are considered a separate value.
+    - The number of unique values in the column is equal to the length of the
+      column, i.e., all values are unique. This is only considered for non-numeric
+      columns. Missing values are considered a separate value. Note that this
+      may lead to dropping columns that contain free-flowing text.
+
     Examples
     --------
     >>> from skrub import DropUninformative
diff --git a/skrub/_expressions/_expressions.py b/skrub/_expressions/_expressions.py
index 62f12c3..7df5d2e 100644
--- a/skrub/_expressions/_expressions.py
+++ b/skrub/_expressions/_expressions.py
@@ -1420,10 +1420,8 @@ def deferred(func):
 
     When this decorator is applied, the resulting function returns expressions.
     The returned expression wraps the call to the original function, and the
-    call is actually executed when the expression is evaluated.
-
-    This allows including a call to any function as a step in a pipeline,
-    rather than executing it immediately.
+    call is executed when the expression is evaluated. This allows including calls
+    to any function as a step in a pipeline, rather than executing it immediately.
 
     See the examples gallery for an in-depth explanation of skrub expressions
     and ``deferred``.
diff --git a/skrub/_expressions/_skrub_namespace.py b/skrub/_expressions/_skrub_namespace.py
index 8ecd2cc..7e1c6ee 100644
--- a/skrub/_expressions/_skrub_namespace.py
+++ b/skrub/_expressions/_skrub_namespace.py
@@ -380,10 +380,6 @@ class SkrubNamespace:
         If ``self`` evaluates to ``True``, the result will be
         ``value_if_true``, otherwise ``value_if_false``.
 
-        The branch which is not selected is not evaluated, which is the main
-        advantage compared to wrapping the conditional statement in a
-        ``@skrub.deferred`` function.
-
         Parameters
         ----------
         value_if_true
@@ -401,6 +397,12 @@ class SkrubNamespace:
         skrub.Expr.skb.match :
             Select based on the value of an expression.
 
+        Notes
+        -----
+        The branch which is not selected is not evaluated, which is the main
+        advantage compared to wrapping the conditional statement in a
+        ``@skrub.deferred`` function.
+
         Examples
         --------
         >>> import skrub
@@ -438,11 +440,10 @@ class SkrubNamespace:
     def match(self, targets, default=NULL):
         """Select based on the value of an expression.
 
-        First, ``self`` is evaluated. Then, the result is compared to the keys
-        in the mapping ``targets``. If a key matches, the corresponding value
-        is evaluated and the result is returned. If there is no match and
-        ``default`` has been provided, ``default`` is evaluated and returned.
-        If there is no match and no default a ``KeyError`` is raised.
+        Evaluate ``self``, then compare the result to the keys in ``targets``.
+        If there is a match, evaluate the corresponding value and return it. If
+        there is no match and ``default`` has been provided, evaluate and return
+        ``default``. Otherwise, a ``KeyError`` is raised.
 
         Therefore, only one of the branches or the default is evaluated which
         is the main advantage compared to placing the selection inside a
@@ -1020,11 +1021,7 @@ class SkrubNamespace:
     def freeze_after_fit(self):
         """Freeze the result during pipeline fitting.
 
-        Note this is an advanced functionality, and the need for it is usually
-        an indication that we need to define a custom scikit-learn transformer
-        that we can use with ``.skb.apply()``.
-
-        When we use ``freeze_after_fit()``, the result of the expression is
+        With ``freeze_after_fit()`` the result of the expression is
         computed during ``fit()``, and then reused (not recomputed) during
         ``transform()`` or ``predict()``.
 
@@ -1032,6 +1029,12 @@ class SkrubNamespace:
         -------
         The expression whose value does not change after ``fit()``
 
+        Notes
+        -----
+        This is an advanced functionality, and the need for it is usually
+        an indication that we need to define a custom scikit-learn transformer
+        that we can use with ``.skb.apply()``.
+
         Examples
         --------
         >>> import skrub
@@ -1282,16 +1285,6 @@ class SkrubNamespace:
         where it was defined) and a display of the intermediate result (or
         error).
 
-        The pipeline is run doing a ``fit_transform``. If ``environment`` is
-        provided, it is used as the bindings for the variables in the
-        expression, and otherwise, the ``value`` attributes of the variables
-        are used.
-
-        At the moment, this creates a directory on the filesystem containing
-        HTML files. The report can be displayed by visiting the contained
-        ``index.html`` in a webbrowser, or passing ``open=True`` (the default)
-        to this method.
-
         Parameters
         ----------
         environment : dict or None (default=None)
@@ -1321,6 +1314,18 @@ class SkrubNamespace:
             way a report is stored at the location indicated by
             ``'report_path'``.
 
+        Notes
+        -----
+        The pipeline is run doing a ``fit_transform``. If ``environment`` is
+        provided, it is used as the bindings for the variables in the
+        expression, and otherwise, the ``value`` attributes of the variables
+        are used.
+
+        At the moment, this creates a directory on the filesystem containing
+        HTML files. The report can be displayed by visiting the contained
+        ``index.html`` in a webbrowser, or passing ``open=True`` (the default)
+        to this method.
+
         Examples
         --------
         >>> # ignore this line:
@@ -1371,22 +1376,10 @@ class SkrubNamespace:
     def get_pipeline(self, *, fitted=False, keep_subsampling=False):
         """Get a skrub pipeline for this expression.
 
-        Returns a :class:`SkrubPipeline`.
-
-        Please see the examples gallery for full information about expressions
-        and the pipelines they generate.
-
-        Provides a skrub pipeline with a ``fit()`` method so we can fit it to some
-        training data and then apply it to unseen data by calling
-        ``transform()`` or ``predict()``.
-
-        An important difference between skrub pipelines and scikit-learn
-        estimators is that ``fit()``, ``transform()`` etc. accept a dictionary
-        of inputs rather than ``X`` and ``y`` arguments (see examples below).
-
-        We can pass ``fitted=True`` to get a pipeline fitted to the data
-        provided as the values in ``skrub.var("name", value=...)`` and
-        ``skrub.X(value)``.
+        Returns a :class:`SkrubPipeline` with a ``fit()`` method so it can be fit
+        to some training data and then apply it to unseen data by calling
+        ``transform()`` or ``predict()``. Unlike scikit-learn estimators, skrub
+        pipelines accept a dictionary of inputs rather than ``X`` and ``y`` arguments.
 
         .. warning::
 
@@ -1400,7 +1393,8 @@ class SkrubNamespace:
         ----------
         fitted : bool (default=False)
             If true, the returned pipeline is fitted to the data provided when
-            initializing variables in the expression.
+            initializing variables in ``skrub.var("name", value=...)`` and
+            ``skrub.X(value)``.
 
         keep_subsampling : bool (default=False)
             If True, and if subsampling has been configured (see
@@ -1451,6 +1445,9 @@ class SkrubNamespace:
         Note that the ``'orders'`` key in the dictionary passed to ``predict``
         corresponds to the name ``'orders'`` in ``skrub.var('orders',
         orders_df)`` above.
+
+        Please see the examples gallery for full information about expressions
+        and the pipelines they generate.
         """
         _check_keep_subsampling(fitted, keep_subsampling)
 
@@ -1549,15 +1546,10 @@ class SkrubNamespace:
         """Find the best parameters with grid search.
 
         This function returns a :class:`ParamSearch`, an object similar to
-        scikit-learn's ``GridSearchCV``. The main difference is that methods
-        such as ``fit()`` and ``predict()`` accept a dictionary of inputs
-        rather than ``X`` and ``y``. Please refer to the examples gallery for
-        an in-depth explanation.
-
-        If the expression contains some numeric ranges (``choose_float``,
-        ``choose_int``), either discretize them by providing the ``n_steps``
-        argument or use ``get_randomized_search`` instead of
-        ``get_grid_search``.
+        scikit-learn's ``GridSearchCV``, 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_``.
 
         Parameters
         ----------
@@ -1585,7 +1577,7 @@ class SkrubNamespace:
         ParamSearch
             An object implementing the hyperparameter search. Besides the usual
             ``fit``, ``predict``, attributes of interest are
-            ``results_`` and ``plot_results()``.
+        ``results_``, ``plot_results()``, and ``best_pipeline_`.
 
         See also
         --------
@@ -1626,6 +1618,28 @@ class SkrubNamespace:
         2  10.0   NaN   logistic             0.80
         3   NaN   3.0         rf             0.65
         4   NaN   NaN      dummy             0.50
+
+        If the expression contains some numeric ranges (``choose_float``,
+        ``choose_int``), either discretize them by providing the ``n_steps``
+        argument or use ``get_randomized_search`` instead of
+        ``get_grid_search``.
+
+        >>> logistic = LogisticRegression(
+        ...     C=skrub.choose_float(0.1, 10.0, log=True, n_steps=5, name="C")
+        ... )
+        >>> pred = X.skb.apply(logistic, y=y)
+        >>> print(pred.skb.describe_param_grid())
+        - C: choose_float(0.1, 10.0, log=True, n_steps=5, name='C')
+        >>> search = pred.skb.get_grid_search(fitted=True)
+        >>> search.results_
+            C	mean_test_score
+        0	0.100000	0.84
+        1	0.316228	0.83
+        2	1.000000	0.81
+        3	3.162278	0.80
+        4	10.000000	0.80
+
+        Please refer to the examples gallery for an in-depth explanation.
         """  # noqa: E501
         _check_keep_subsampling(fitted, keep_subsampling)
         _check_grid_search_possible(self._expr)
@@ -1643,10 +1657,10 @@ class SkrubNamespace:
         """Find the best parameters with randomized search.
 
         This function returns a :class:`ParamSearch`, an object similar to
-        scikit-learn's :class:`~sklearn.model_selection.RandomizedSearchCV`. The main
-        difference is that methods such as ``fit()`` and ``predict()`` accept a
-        dictionary of inputs rather than ``X`` and ``y``. Please refer to the examples
-        gallery for an in-depth explanation.
+        scikit-learn's :class:`~sklearn.model_selection.RandomizedSearchCV`, where
+        the main difference is ``fit()`` and ``predict()`` accept a
+        dictionary of inputs rather than ``X`` and ``y``. The best pipeline can
+        be returned by calling ``.best_pipeline_``.
 
         Parameters
         ----------
@@ -1674,7 +1688,7 @@ class SkrubNamespace:
         ParamSearch
             An object implementing the hyperparameter search. Besides the usual
             ``fit``, ``predict``, attributes of interest are
-            ``results_`` and ``plot_results()``.
+            ``results_``, ``plot_results()``, and ``best_pipeline_`.
 
         See also
         --------
@@ -1725,6 +1739,8 @@ class SkrubNamespace:
         7   4       NaN  NaN      dummy             0.50
         8   9       NaN  NaN      dummy             0.50
         9   5       NaN  NaN      dummy             0.50
+
+        Please refer to the examples gallery for an in-depth explanation.
         """  # noqa: E501
         _check_keep_subsampling(fitted, keep_subsampling)
 
@@ -1957,12 +1973,17 @@ class SkrubNamespace:
     def mark_as_X(self):
         """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
+        on the entire data and cannot benefit from hyperparameter tuning.
         Returns a copy; the original expression is left unchanged.
 
-        This is used for cross-validation and hyperparameter selection: the
-        nodes marked with ``.skb.mark_as_X()`` and ``.skb.mark_as_y()`` define
-        the cross-validation splits.
+        Returns
+        -------
+        The input expression, which has been marked as being ``X``
 
+        Notes
+        -----
         During cross-validation, all the previous steps are first executed,
         until X and y have been materialized. Then, those are split into
         training and testing sets. The following steps in the expression are
@@ -1977,14 +1998,8 @@ class SkrubNamespace:
         ``skrub.X(value)`` can be used as a shorthand for
         ``skrub.var('X', value).skb.mark_as_X()``.
 
-        Please see the examples gallery for more information.
-
         Note: this marks the expression in-place and also returns it.
 
-        Returns
-        -------
-        The input expression, which has been marked as being ``X``
-
         Examples
         --------
         >>> import skrub
@@ -2018,6 +2033,8 @@ class SkrubNamespace:
         computed. Then, they are split into training and test sets. Then the
         rest of the pipeline (in this case the last step, the
         ``DummyClassifier``) is evaluated on those splits.
+
+        Please see the examples gallery for more information.
         """
         new = self._expr._skrub_impl.__copy__()
         new.is_X = True
@@ -2030,14 +2047,19 @@ class SkrubNamespace:
 
     @check_expr
     def mark_as_y(self):
-        """Mark this expression as being the ``X`` table.
+        """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
+        on the entire data and cannot benefit from hyperparameter tuning.
         Returns a copy; the original expression is left unchanged.
 
-        This is used for cross-validation and hyperparameter selection: the
-        nodes marked with ``.skb.mark_as_X()`` and ``.skb.mark_as_y()`` define
-        the cross-validation splits.
+        Returns
+        -------
+        The input expression, which has been marked as being ``y``
 
+        Notes
+        -----
         During cross-validation, all the previous steps are first executed,
         until X and y have been materialized. Then, those are split into
         training and testing sets. The following steps in the expression are
@@ -2052,14 +2074,8 @@ class SkrubNamespace:
         ``skrub.y(value)`` can be used as a shorthand for
         ``skrub.var('y', value).skb.mark_as_y()``.
 
-        Please see the examples gallery for more information.
-
         Note: this marks the expression in-place and also returns it.
 
-        Returns
-        -------
-        The input expression, which has been marked as being ``y``
-
         Examples
         --------
         >>> import skrub
@@ -2090,6 +2106,8 @@ class SkrubNamespace:
         computed. Then, they are split into training and test sets. Then the
         rest of the pipeline (in this case the last step, the
         ``DummyClassifier``) is evaluated on those splits.
+
+        Please see the examples gallery for more information.
         """
         new = self._expr._skrub_impl.__copy__()
         new.is_y = True
@@ -2104,7 +2122,8 @@ class SkrubNamespace:
     def set_name(self, name):
         """Give a name to this expression.
 
-        Returns a modified copy
+        Returns a modified copy.
+
         The name is displayed in the graph and reports so this can be useful to
         mark relevant parts of the pipeline.
 
diff --git a/skrub/_join_utils.py b/skrub/_join_utils.py
index 4faeced..7445624 100644
--- a/skrub/_join_utils.py
+++ b/skrub/_join_utils.py
@@ -263,7 +263,12 @@ def left_join(left, right, left_on, right_on, rename_right_cols="{}"):
 
 @dispatch
 def _do_left_join(left, right, left_on, right_on):
-    raise NotImplementedError()
+    # This function is accessed only when the first argument is neither
+    # a pandas or polars DataFrame.
+    # To avoid circular import:
+    from skrub._dataframe._common import _raise as _sbd_raise
+
+    raise _sbd_raise(left, kind="DataFrame")
 
 
 @_do_left_join.specialize("pandas", argument_type="DataFrame")
diff --git a/skrub/_reporting/_data/templates/column-summaries.html b/skrub/_reporting/_data/templates/column-summaries.html
index 3e52a4a..f276be9 100644
--- a/skrub/_reporting/_data/templates/column-summaries.html
+++ b/skrub/_reporting/_data/templates/column-summaries.html
@@ -30,8 +30,7 @@
         </div>
         {{buttons.dismissbutton()}}
     </div>
-    {% endif %}
-
+    {% else %}
     <div class="horizontal-scroll padding-b-s">
 	<div class="column-summary-group">
 	    {% for column in summary.columns %}
@@ -40,6 +39,7 @@
 	    {% endfor %}
 	</div>
     </div>
+    {% endif %}
 </article>
 
 {% include "no-filter-matches.html" %}
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..739409b 100644
--- a/skrub/_reporting/_data/templates/dataframe-sample.html
+++ b/skrub/_reporting/_data/templates/dataframe-sample.html
@@ -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/_sample_table.py b/skrub/_reporting/_sample_table.py
index d3c5683..2c2d452 100644
--- a/skrub/_reporting/_sample_table.py
+++ b/skrub/_reporting/_sample_table.py
@@ -65,6 +65,7 @@ Table Cell
 import pandas as pd
 
 from .. import _dataframe as sbd
+from .._dataframe._common import _raise as _sbd_raise
 from .._dispatch import dispatch
 
 __all__ = ["make_table"]
@@ -89,7 +90,7 @@ def make_table(df, max_top_slice_size=5, max_bottom_slice_size=5):
     A dictionary with all the information needed by the html template, see this
     module's docstring for details.
     """
-    raise NotImplementedError()
+    raise _sbd_raise(df, kind="DataFrame")
 
 
 @make_table.specialize("pandas", argument_type="DataFrame")
diff --git a/skrub/_reporting/_utils.py b/skrub/_reporting/_utils.py
index aca2f22..28c73f2 100644
--- a/skrub/_reporting/_utils.py
+++ b/skrub/_reporting/_utils.py
@@ -7,6 +7,7 @@ import unicodedata
 import numpy as np
 
 from skrub import _dataframe as sbd
+from skrub._dataframe._common import _raise as _sbd_raise
 from skrub._dispatch import dispatch
 
 
@@ -16,7 +17,7 @@ def get_dtype_name(column):
 
 @dispatch
 def to_dict(df):
-    raise NotImplementedError()
+    raise _sbd_raise(df, kind="DataFrame")
 
 
 @to_dict.specialize("pandas", argument_type="DataFrame")
diff --git a/skrub/_table_vectorizer.py b/skrub/_table_vectorizer.py
index 87172d3..02cea6f 100644
--- a/skrub/_table_vectorizer.py
+++ b/skrub/_table_vectorizer.py
@@ -398,38 +398,10 @@ class Cleaner(TransformerMixin, BaseEstimator):
 class TableVectorizer(TransformerMixin, BaseEstimator):
     """Transform a dataframe to a numeric (vectorized) representation.
 
-    Applies a different transformation to each of several kinds of columns:
-
-    - `numeric`: floats, integers, and booleans.
-    - `datetime`: datetimes and dates.
-    - `low_cardinality`: string and categorical columns with a count
-      of unique values smaller than a given threshold (40 by default). Category encoding
-      schemes such as one-hot encoding, ordinal encoding etc. are typically appropriate
-      for columns with few unique values.
-    - `high_cardinality`: string and categorical columns with many
-      unique values, such as free-form text. Such columns have so many distinct values
-      that it is not possible to assign a distinct representation to each: the dimension
-      would be too large and there would be too few examples of each category.
-      Representations designed for text, such as topic modelling
-      (:class:`~skrub.GapEncoder`) or locality-sensitive hashing
-      (:class:`~skrub.MinHash`) are more appropriate.
-
-    .. note::
-
-        Transformations are applied **independently on each column**. A
-        different transformer instance is used for each column separately;
-        multivariate transformations are therefore not supported.
-
-    The transformer for each kind of column can be configured with the corresponding
-    parameter. A transformer is expected to be a `compatible scikit-learn transformer
-    <https://scikit-learn.org/stable/glossary.html#term-transformer>`_. Special-cased
-    strings ``"drop"`` and ``"passthrough"`` are accepted as well, to indicate to drop
-    the columns or to pass them through untransformed, respectively.
-
-    Additionally, it is possible to specify transformers for specific columns,
-    overriding the categorization described above. This is done by providing a
-    list of pairs ``(transformer, list_of_columns)`` as the
-    ``specific_transformers`` parameter.
+    This transformer preprocesses the given dataframe by first cleaning the data
+    to ensure consistent numerical dtypes (float32), then encodes each column with
+    an encoder suitable for its dtype. Categorical features are encoded differently
+    depending on their cardinality.
 
     .. note::
 
@@ -559,6 +531,41 @@ class TableVectorizer(TransformerMixin, BaseEstimator):
         Preprocesses each column of a dataframe with consistency checks and
         sanitization, e.g., of null values or dates.
 
+    Notes
+    -----
+    The TableVectorizer applies a different transformation to each of several kinds of columns:
+
+    - `numeric`: floats, integers, and booleans.
+    - `datetime`: datetimes and dates.
+    - `low_cardinality`: string and categorical columns with a count
+      of unique values smaller than a given threshold (40 by default). Category encoding
+      schemes such as one-hot encoding, ordinal encoding etc. are typically appropriate
+      for columns with few unique values.
+    - `high_cardinality`: string and categorical columns with many
+      unique values, such as free-form text. Such columns have so many distinct values
+      that it is not possible to assign a distinct representation to each: the dimension
+      would be too large and there would be too few examples of each category.
+      Representations designed for text, such as topic modelling
+      (:class:`~skrub.GapEncoder`) or locality-sensitive hashing
+      (:class:`~skrub.MinHash`) are more appropriate.
+
+    .. note::
+
+        Transformations are applied **independently on each column**. A
+        different transformer instance is used for each column separately;
+        multivariate transformations are therefore not supported.
+
+    The transformer for each kind of column can be configured with the corresponding
+    parameter. A transformer is expected to be a `compatible scikit-learn transformer
+    <https://scikit-learn.org/stable/glossary.html#term-transformer>`_. Special-cased
+    strings ``"drop"`` and ``"passthrough"`` are accepted as well, to indicate to drop
+    the columns or to pass them through untransformed, respectively.
+
+    Additionally, it is possible to specify transformers for specific columns,
+    overriding the categorization described above. This is done by providing a
+    list of pairs ``(transformer, list_of_columns)`` as the
+    ``specific_transformers`` parameter.
+
     Examples
     --------
     >>> from skrub import TableVectorizer
diff --git a/skrub/_text_encoder.py b/skrub/_text_encoder.py
index ef0d473..b07fd71 100644
--- a/skrub/_text_encoder.py
+++ b/skrub/_text_encoder.py
@@ -32,23 +32,6 @@ class TextEncoder(SingleColumnTransformer, TransformerMixin):
         dependencies for skrub. See the "deep learning dependencies" section
         in the :ref:`installation_instructions` guide for more details.
 
-    This class uses a pre-trained model, so calling ``fit`` or ``fit_transform``
-    will not train or fine-tune the model. Instead, the model is loaded from disk,
-    and a PCA is fitted to reduce the dimension of the language model's output,
-    if ``n_components`` is not None.
-
-    When PCA is disabled, this class is essentially stateless, with loading the
-    pre-trained model from disk being the only difference between ``fit_transform``
-    and ``transform``.
-
-    Be aware that parallelizing this class (e.g., using
-    :class:`~skrub.TableVectorizer` with ``n_jobs`` > 1) may be computationally
-    expensive. This is because a copy of the pre-trained model is loaded into memory
-    for each thread. Therefore, we recommend you to let the default n_jobs=None
-    (or set to 1) of the TableVectorizer and let pytorch handle parallelism.
-
-    If memory usage is a concern, check the characteristics of your selected model.
-
     Parameters
     ----------
     model_name : str, default="intfloat/e5-small-v2"
@@ -153,6 +136,25 @@ class TextEncoder(SingleColumnTransformer, TransformerMixin):
     SimilarityEncoder :
         Encode string columns as a numeric array with n-gram string similarity.
 
+    Notes
+    -----
+    This class uses a pre-trained model, so calling ``fit`` or ``fit_transform``
+    will not train or fine-tune the model. Instead, the model is loaded from disk,
+    and a PCA is fitted to reduce the dimension of the language model's output,
+    if ``n_components`` is not None.
+
+    When PCA is disabled, this class is essentially stateless, with loading the
+    pre-trained model from disk being the only difference between ``fit_transform``
+    and ``transform``.
+
+    Be aware that parallelizing this class (e.g., using
+    :class:`~skrub.TableVectorizer` with ``n_jobs`` > 1) may be computationally
+    expensive. This is because a copy of the pre-trained model is loaded into memory
+    for each thread. Therefore, we recommend you to let the default n_jobs=None
+    (or set to 1) of the TableVectorizer and let pytorch handle parallelism.
+
+    If memory usage is a concern, check the characteristics of your selected model.
+
     References
     ----------
     .. [1]  L. Grinsztajn, M. Kim, E. Oyallon, G. Varoquaux
diff --git a/skrub/_to_categorical.py b/skrub/_to_categorical.py
index 0fd8f53..ab18d52 100644
--- a/skrub/_to_categorical.py
+++ b/skrub/_to_categorical.py
@@ -8,7 +8,14 @@ class ToCategorical(SingleColumnTransformer):
     """
     Convert a string column to Categorical dtype.
 
-    The main benefit is that categorical columns can then be recognized by
+    This transformer ensures that a given string or categorical column has
+    Categorical dtype. This is done to mark columns to be treated as categorical
+    by downstream transformers and learners.
+
+    Notes
+    -----
+    The main benefit of converting columns to categorical is that categorical
+    columns can be recognized by
     scikit-learn's ``HistGradientBoostingRegressor`` and
     ``HistGradientBoostingClassifier`` with their
     ``categorical_features='from_dtype'`` option. This transformer is therefore
diff --git a/skrub/_to_datetime.py b/skrub/_to_datetime.py
index c699e61..56f5094 100644
--- a/skrub/_to_datetime.py
+++ b/skrub/_to_datetime.py
@@ -7,6 +7,7 @@ from sklearn.utils.validation import check_is_fitted
 
 from . import _dataframe as sbd
 from . import selectors as s
+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
@@ -18,7 +19,7 @@ _SAMPLE_SIZE = 30
 
 @dispatch
 def _get_time_zone(col):
-    raise NotImplementedError()
+    raise _sbd_raise(col, kind="Series")
 
 
 @_get_time_zone.specialize("pandas", argument_type="Column")
@@ -44,7 +45,7 @@ def _get_time_zone_polars(col):
 
 @dispatch
 def _convert_time_zone(col, time_zone):
-    raise NotImplementedError()
+    raise _sbd_raise(col, kind="Series")
 
 
 @_convert_time_zone.specialize("pandas", argument_type="Column")
@@ -85,15 +86,9 @@ class ToDatetime(SingleColumnTransformer):
     """
     Parse datetimes represented as strings and return ``Datetime`` columns.
 
-    An input column is converted to a column with dtype Datetime if possible,
-    and rejected by raising a ``RejectColumn`` exception otherwise. Only Date,
-    Datetime, String, and pandas object columns are handled, other dtypes are
-    rejected with ``RejectColumn``.
-
-    Once a column is accepted, outputs of ``transform`` always have the same
-    Datetime dtype (including resolution and time zone). Once the transformer
-    is fitted, entries that fail to be converted during subsequent calls to
-    ``transform`` are replaced with nulls.
+    This transformer tries to convert the given column from string to datetime,
+    by either testing common datetime formats or using the ``format`` specified
+    by the user. Columns that are not strings, dates or datetimes raise an exception.
 
     Parameters
     ----------
@@ -127,6 +122,18 @@ class ToDatetime(SingleColumnTransformer):
         is ``None``; otherwise it is the name of the time zone such as ``UTC`` or
         ``Europe/Paris``.
 
+    Notes
+    -----
+    An input column is converted to a column with dtype Datetime if possible,
+    and rejected by raising a ``RejectColumn`` exception otherwise. Only Date,
+    Datetime, String, and pandas object columns are handled, other dtypes are
+    rejected with ``RejectColumn``.
+
+    Once a column is accepted, outputs of ``transform`` always have the same
+    Datetime dtype (including resolution and time zone). Once the transformer
+    is fitted, entries that fail to be converted during subsequent calls to
+    ``transform`` are replaced with nulls.
+
     Examples
     --------
     >>> import pandas as pd
diff --git a/skrub/selectors/__init__.py b/skrub/selectors/__init__.py
index d30af09..23f14a0 100644
--- a/skrub/selectors/__init__.py
+++ b/skrub/selectors/__init__.py
@@ -2,174 +2,7 @@
 Helpers for selecting columns in a dataframe.
 =============================================
 
-A selector represents a column selection rule, such as "all columns that have
-numerical data types, except the column 'User ID'". When applied to a dataframe,
-it expands into a list of column names that match the selection rule.
-This is implemented by the ``Selector`` class and its ``expand`` method.
-
-The 2 advantages offered by selectors are:
-
-- Expressing complex selection rules in a simple and concise way, because they
-  can be combined with operators and a range of useful selectors is provided by
-  this module.
-- Delayed selection: passing a selection rule, to be evaluated later on a
-  dataframe that is not yet available. For example, without selectors (i.e. ATM)
-  it is not possible to instantiate a skrub.SelectCols that selects "all
-  columns that have numerical data types, except the column 'User ID'", if the
-  data on which it will be fitted is not yet available.
-
-Here is an example dataframe:
-
->>> import pandas as pd
->>> df = pd.DataFrame(
-...     {
-...         "height_mm": [297.0, 420.0],
-...         "width_mm": [210.0, 297.0],
-...         "kind": ["A4", "A3"],
-...         "ID": [4, 3],
-...     }
-... )
-
-A simple kind of selector selects a fixed list of column names. Such selectors
-are created with the ``cols()`` function.
-
->>> from skrub import selectors as s
->>> mm_cols = s.cols("height_mm", "width_mm")
->>> mm_cols
-cols('height_mm', 'width_mm')
->>> mm_cols.expand(df)
-['height_mm', 'width_mm']
-
-Another simple selector, especially useful for default arguments, keeps all
-columns:
-
->>> s.all().expand(df)
-['height_mm', 'width_mm', 'kind', 'ID']
-
-Selectors can be combined with operators, for example if we wanted all columns
-except the "mm" columns above:
-
->>> (s.all() - s.cols("height_mm", "width_mm")).expand(df)
-['kind', 'ID']
-
-Several kinds of selectors are provided by this module, to select columns by
-name, data type, contents or with arbitrary user-provided rules.
-
->>> s.numeric().expand(df)
-['height_mm', 'width_mm', 'ID']
->>> s.glob('*_mm').expand(df)
-['height_mm', 'width_mm']
-
-See the full list:
-
->>> s.ALL_SELECTORS
-['Filter', 'NameFilter', 'all', 'any_date', 'boolean', 'cardinality_below', 'categorical', 'cols', 'filter', 'filter_names', 'float', 'glob', 'has_nulls', 'integer', 'inv', 'numeric', 'regex', 'string']
-
-The available operators are |, &, -, ^ with the usual meaning (the same meaning
-they would on python sets of the selected column names), and ~ to invert a
-selection.
-
->>> s.glob('*_mm').expand(df)
-['height_mm', 'width_mm']
->>> (~s.glob('*_mm')).expand(df)
-['kind', 'ID']
->>> (s.glob('*_mm') | s.cols('ID')).expand(df)
-['height_mm', 'width_mm', 'ID']
->>> (s.glob('*_mm') & s.glob('height_*')).expand(df)
-['height_mm']
->>> (s.glob('*_mm') ^ s.string()).expand(df)
-['height_mm', 'width_mm', 'kind']
-
-The operators respect the usual short-circuit rules so for example the
-following selector:
-
->>> s.categorical() & s.cardinality_below(10)
-(categorical() & cardinality_below(10))
-
-Will not compute the cardinality of non-categorical columns.
-
-Selecting columns by name
--------------------------
-
-A column name or sequence of column names is converted to a ``cols`` selector
-when combined with other selectors in an expression.
-
->>> s.numeric() - "ID"
-(numeric() - cols('ID'))
->>> (s.numeric() - "ID").expand(df)
-['height_mm', 'width_mm']
-
-More generally, a column name or list of column names should be accepted by all
-skrub public interfaces that accept a selector. This is made easy by the
-``make_selector`` function.
-
->>> s.make_selector(s.all())
-all()
->>> s.make_selector("ID")
-cols('ID')
->>> s.make_selector(["ID", "kind"])
-cols('ID', 'kind')
-
-The cols selector, when used by itself, raises if some of the columns are
-missing from the dataframe.
-
->>> s.cols("ID", "missing").expand(df)
-Traceback (most recent call last):
-    ...
-ValueError: The following columns are requested for selection but missing from dataframe: ['missing']
-
-However, that is no longer the case as soon as it is combined with other selectors.
-Indeed, when combining the cols selector with other selectors, we are no longer
-performing a simple indexing of the dataframe with a list of columns that we
-know exist.
-
->>> (s.all() & s.cols("ID", "missing")).expand(df)
-['ID']
-
-Selectors in skrub interfaces
------------------------------
-
-In practice, the ``expand`` method of selectors will rarely be called directly by
-client code. Rather, users would create selectors and pass them instead of a
-column list to skrub objects that operate on a subset of columns. This could
-be, for example, the cols parameter of SelectCols: SelectCols(s.glob("*_mm")).
-
-One example of a (private) function that consumes selectors is the ``select``
-function provided in this module.
-
->>> s.select(df, ["ID", "kind"])
-   ID kind
-0   4   A4
-1   3   A3
->>> s.select(df, s.numeric() - "ID")
-   height_mm  width_mm
-0      297.0     210.0
-1      420.0     297.0
-
-Advanced selectors: filter and filter_names
--------------------------------------------
-
-``filter`` and ``filter_names`` allow selecting columns based on arbitrary user-defined
-criteria. These are also used to implement many of the other selectors provided
-in this module.
-
-``filter`` accepts a predicate that is called with a column (pandas or polars
-Series) and returns True if it should be selected.
-
->>> s.filter(lambda col: "A4" in col.tolist()).expand(df)
-['kind']
-
-``filter_names`` accepts a predicate that is passed the column name, instead of the
-column.
-
->>> s.filter_names(lambda name: name.endswith('mm')).expand(df)
-['height_mm', 'width_mm']
-
-We can pass args and kwargs that will be passed to the predicate, which can
-help avoid lambda or local functions and thus ensure the selector is picklable.
-
->>> s.filter_names(str.endswith, 'mm').expand(df)
-['height_mm', 'width_mm']
+See the User Guide selectors page for the public-facing part of the selector API.
 
 Defining new selectors
 ----------------------
@@ -195,6 +28,16 @@ appropriate predicate and arguments; most selectors offered by this module are
 implemented with this approach.
 
 >>> from skrub import _dataframe as sbd
+>>> from skrub import selectors as s
+>>> import pandas as pd
+>>> df = pd.DataFrame(
+...     {
+...         "height_mm": [297.0, 420.0],
+...         "width_mm": [210.0, 297.0],
+...         "kind": ["A4", "A3"],
+...         "ID": [4, 3],
+...     }
+... )
 
 Defining a new class:
 
diff --git a/skrub/selectors/_base.py b/skrub/selectors/_base.py
index a870d0d..57b4049 100644
--- a/skrub/selectors/_base.py
+++ b/skrub/selectors/_base.py
@@ -1,4 +1,5 @@
 from .. import _dataframe as sbd
+from .._dataframe._common import _raise as _sbd_raise
 from .._dispatch import dispatch
 from .._utils import repr_args
 
@@ -159,7 +160,7 @@ def make_selector(obj):
 
 @dispatch
 def _select_col_names(df, col_names):
-    raise NotImplementedError()
+    raise _sbd_raise(df, kind="DataFrame")
 
 
 @_select_col_names.specialize("pandas", argument_type="DataFrame")


Test patch:
diff --git a/skrub/_dataframe/tests/test_common.py b/skrub/_dataframe/tests/test_common.py
index 56f4529..2b1fe6a 100644
--- a/skrub/_dataframe/tests/test_common.py
+++ b/skrub/_dataframe/tests/test_common.py
@@ -38,7 +38,7 @@ def test_not_implemented():
         func = getattr(ns, func_name)
         n_params = len(inspect.signature(func).parameters)
         params = [None] * n_params
-        with pytest.raises(NotImplementedError):
+        with pytest.raises(TypeError):
             func(*params)
 
 
@@ -97,7 +97,7 @@ def test_to_numpy(df_module, example_data_dict):
 
 
 def test_to_pandas(df_module, pd_module):
-    with pytest.raises(NotImplementedError):
+    with pytest.raises(TypeError):
         ns.to_pandas(np.arange(3))
 
     if df_module.name == "pandas":
diff --git a/skrub/_reporting/tests/test_table_report.py b/skrub/_reporting/tests/test_table_report.py
index bab7ae2..e28f6b0 100644
--- a/skrub/_reporting/tests/test_table_report.py
+++ b/skrub/_reporting/tests/test_table_report.py
@@ -5,10 +5,12 @@ import re
 import warnings
 from pathlib import Path
 
+import numpy as np
 import pytest
 
-from skrub import TableReport, ToDatetime
+from skrub import TableReport, ToDatetime, datasets
 from skrub import _dataframe as sbd
+from skrub._reporting._sample_table import make_table
 
 
 def get_report_id(html):
@@ -270,6 +272,12 @@ def test_minimal_mode(pd_module):
     assert 'id="column-associations-panel"' not in html
 
 
+def test_error_input_type():
+    df = datasets.fetch_employee_salaries()
+    with pytest.raises(TypeError):
+        TableReport(df)
+
+
 def test_single_column_report(df_module):
     # Check that single column report works
     single_col = df_module.example_column
@@ -279,6 +287,12 @@ def test_single_column_report(df_module):
     assert col_name in html
 
 
+def test_error_make_table():
+    # Make codecov happy
+    with pytest.raises(TypeError, match="Expecting a Pandas or Polars DataFrame"):
+        make_table(np.array([1]))
+
+
 @pytest.mark.parametrize("arg", ["max_plot_columns", "max_association_columns"])
 def test_bad_cols_parameter(pd_module, arg):
     df = pd_module.example_dataframe
diff --git a/skrub/selectors/tests/test_selectors.py b/skrub/selectors/tests/test_selectors.py
index a542a69..d89b0ea 100644
--- a/skrub/selectors/tests/test_selectors.py
+++ b/skrub/selectors/tests/test_selectors.py
@@ -2,10 +2,12 @@ import inspect
 import pickle
 import types
 
+import numpy as np
 import pytest
 
 from skrub import _dataframe as sbd
 from skrub import selectors as s
+from skrub.selectors._base import _select_col_names
 
 
 def test_repr():
@@ -135,3 +137,8 @@ def test_pickling_selectors_with_args(df_module):
     # attribute
     with pytest.raises(Exception):
         pickle.dumps(s.filter(lambda _: False))
+
+
+def test_error_select_col_names():
+    with pytest.raises(TypeError, match="Expecting a Pandas or Polars DataFrame"):
+        _select_col_names(np.array([1]), col_names=None)
diff --git a/skrub/tests/test_agg_joiner.py b/skrub/tests/test_agg_joiner.py
index 67e462f..1d3d132 100644
--- a/skrub/tests/test_agg_joiner.py
+++ b/skrub/tests/test_agg_joiner.py
@@ -7,7 +7,7 @@ import pytest
 from sklearn.exceptions import NotFittedError
 
 from skrub import _dataframe as sbd
-from skrub._agg_joiner import AggJoiner, AggTarget, aggregate
+from skrub._agg_joiner import AggJoiner, AggTarget, aggregate, perform_groupby
 
 
 @pytest.fixture
@@ -781,3 +781,9 @@ def test_agg_target_duplicate_columns(main_table, y_df):
     out_1 = agg_target.fit_transform(X, y_df)
     out_2 = agg_target.transform(X)
     assert sbd.column_names(out_1) == sbd.column_names(out_2)
+
+
+def test_error_perform_groupby():
+    # Make codecov happy
+    with pytest.raises(TypeError, match="Expecting a Pandas or Polars DataFrame"):
+        perform_groupby(np.array([1]), key=None, cols_to_agg=None, operations=None)
diff --git a/skrub/tests/test_clean_categories.py b/skrub/tests/test_clean_categories.py
index 1ef53f5..2696162 100644
--- a/skrub/tests/test_clean_categories.py
+++ b/skrub/tests/test_clean_categories.py
@@ -1,9 +1,10 @@
+import numpy as np
 import pandas as pd
 import pytest
 from pandas.testing import assert_series_equal
 
 from skrub import _dataframe as sbd
-from skrub._clean_categories import CleanCategories
+from skrub._clean_categories import CleanCategories, _with_string_categories
 from skrub._on_each_column import RejectColumn
 
 
@@ -47,3 +48,9 @@ def test_clean_categories(df_module):
     cleaner = CleanCategories().fit(s)
     for vals in ["x", "y", None], [1.1, 2.2, None]:
         assert sbd.is_categorical(cleaner.transform(df_module.make_column("c", vals)))
+
+
+def test_error_with_string_categories():
+    # Make codecov happy
+    with pytest.raises(TypeError, match="Expecting a Pandas or Polars Series"):
+        _with_string_categories(np.array([1]))
diff --git a/skrub/tests/test_clean_null_strings.py b/skrub/tests/test_clean_null_strings.py
index 840dd76..bfcc826 100644
--- a/skrub/tests/test_clean_null_strings.py
+++ b/skrub/tests/test_clean_null_strings.py
@@ -1,6 +1,7 @@
+import numpy as np
 import pytest
 
-from skrub._clean_null_strings import CleanNullStrings
+from skrub._clean_null_strings import CleanNullStrings, _trim_whitespace_only
 from skrub._on_each_column import RejectColumn
 
 
@@ -20,3 +21,9 @@ def test_reject_non_string_cols(df_module):
     out = cleaner.transform(df_module.example_column)
     # in this case no substitutions need to be made
     assert out is df_module.example_column
+
+
+def test_error_trim_whitespace_only():
+    # Make codecov happy
+    with pytest.raises(TypeError, match="Expecting a Pandas or Polars Series"):
+        _trim_whitespace_only(np.array([1]))
diff --git a/skrub/tests/test_datetime_encoder.py b/skrub/tests/test_datetime_encoder.py
index 7b89780..b03afa3 100644
--- a/skrub/tests/test_datetime_encoder.py
+++ b/skrub/tests/test_datetime_encoder.py
@@ -1,11 +1,18 @@
+from functools import partial
 from types import SimpleNamespace
 
+import numpy as np
 import pytest
 
 from skrub import DatetimeEncoder
 from skrub import _dataframe as sbd
 from skrub import selectors as s
-from skrub._datetime_encoder import _CircularEncoder, _SplineEncoder
+from skrub._datetime_encoder import (
+    _CircularEncoder,
+    _get_dt_feature,
+    _is_date,
+    _SplineEncoder,
+)
 from skrub._on_each_column import OnEachColumn
 from skrub._to_float32 import ToFloat32
 
@@ -350,3 +357,9 @@ def test_error_checking_periodic_encoder(a_datetime_col):
 
     with pytest.raises(ValueError, match=r"Unsupported value (\S+) for (\S+)"):
         enc.fit_transform(a_datetime_col)
+
+
+@pytest.mark.parametrize("func", (_is_date, partial(_get_dt_feature, feature=None)))
+def test_error_dispatch(func):
+    with pytest.raises(TypeError, match="Expecting a Pandas or Polars Series"):
+        func(np.array([1]))
diff --git a/skrub/tests/test_join_utils.py b/skrub/tests/test_join_utils.py
index 6dd1dce..ad169fa 100644
--- a/skrub/tests/test_join_utils.py
+++ b/skrub/tests/test_join_utils.py
@@ -188,3 +188,10 @@ def test_left_join_types_not_equal(df_module, left):
         _join_utils.left_join(
             left, right=right, left_on="left_key", right_on="right_key"
         )
+
+
+def test_error_do_left_join():
+    with pytest.raises(TypeError, match="Expecting a Pandas or Polars DataFrame"):
+        _join_utils._do_left_join(
+            np.array([1]), right=None, left_on=None, right_on=None
+        )
diff --git a/skrub/tests/test_to_datetime.py b/skrub/tests/test_to_datetime.py
index 915abc1..9f12bd1 100644
--- a/skrub/tests/test_to_datetime.py
+++ b/skrub/tests/test_to_datetime.py
@@ -1,5 +1,7 @@
 from datetime import timezone
+from functools import partial
 
+import numpy as np
 import pandas as pd
 import pytest
 from sklearn.utils.fixes import parse_version
@@ -7,7 +9,12 @@ from sklearn.utils.fixes import parse_version
 from skrub import _dataframe as sbd
 from skrub._dispatch import dispatch
 from skrub._on_each_column import OnEachColumn, RejectColumn
-from skrub._to_datetime import ToDatetime, to_datetime
+from skrub._to_datetime import (
+    ToDatetime,
+    _convert_time_zone,
+    _get_time_zone,
+    to_datetime,
+)
 
 ISO = "%Y-%m-%dT%H:%M:%S"
 
@@ -214,3 +221,12 @@ def test_to_datetime_func(df_module, datetime_col):
     float_col = df_module.example_column
     assert sbd.is_float(float_col)
     assert to_datetime(float_col) is float_col
+
+
+@pytest.mark.parametrize(
+    "func", [partial(_convert_time_zone, time_zone=None), _get_time_zone]
+)
+def test_error_dispatch(func):
+    # Make codecov happy
+    with pytest.raises(TypeError, match="Expecting a Pandas or Polars Series"):
+        func(np.array([1]))