{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "\n# Supporting callbacks in third party estimators\n\n.. currentmodule:: sklearn.callback\n\nThis document shows how to make third party :term:`estimators` and\n:term:`meta-estimators` compatible with the callback infrastructure supported by\nscikit-learn.\n\nGenerally speaking, a callback is a function that is provided by the user to be called\nat specific steps of a process, or to be triggered by specific events. Callbacks provide\na clean mechanism for inserting custom logic like monitoring progress or metrics,\nwithout modifying the core algorithm of the process.\n\nIn scikit-learn, callbacks take the form of classes following a [protocol](https://typing.python.org/en/latest/spec/protocol.html)_. This protocol requires the\ncallback classes to implement specific methods (referred to as callback hooks) which\nare called at specific steps of the fitting of an estimator or a meta-estimator.\nThese hooks are :meth:`~FitCallback.setup`, :meth:`~FitCallback.on_fit_task_begin`,\n:meth:`~FitCallback.on_fit_task_end` and :meth:`~FitCallback.teardown`. The\n:meth:`~FitCallback.setup` and :meth:`~FitCallback.teardown` hooks are called only once,\nrespectively at the start and end of the estimator's :term:`fit` method, and are\nresponsible for setting up and shutting down the callback. The\n:meth:`~FitCallback.on_fit_task_begin` and :meth:`~FitCallback.on_fit_task_end` hooks\nare respectively called at the beginning and end of each task in `fit` and are\nresponsible for the actual callback work. In scikit-learn estimators, a task in `fit` is\nusually one step of a loop, with nested loops corresponding to nested tasks. In general,\na task can be whatever unit of work the estimator's developer wants it to be.\n\nIn order to support the callbacks, estimators need to initialize and manage\n:class:`~CallbackContext` objects. As the name implies, these objects hold the\ncontextual information necessary to run the callback hooks. They are also responsible\nfor calling the callback hooks at the right time.\n\nIn the following, we show how to convert an example estimator class and an example\nmeta-estimator class to make them compliant with the scikit-learn callback\ninfrastructure.\n\nFirst a few imports and some random data for the rest of the script.\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "# Authors: The scikit-learn developers\n# SPDX-License-Identifier: BSD-3-Clause" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "import numpy as np\n\nfrom sklearn.base import BaseEstimator, clone\nfrom sklearn.callback import CallbackSupportMixin, ProgressBar, with_callbacks\nfrom sklearn.metrics.pairwise import euclidean_distances\nfrom sklearn.model_selection import check_cv\nfrom sklearn.utils import check_random_state\nfrom sklearn.utils.parallel import Parallel, delayed\nfrom sklearn.utils.validation import check_is_fitted, validate_data\n\nn_samples, n_features = 100, 4\nrng = np.random.RandomState(42)\nX = rng.rand(n_samples, n_features)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Custom Estimator\nHere we demonstrate how to implement a custom estimator that supports callbacks. For\nthe example, a simplified version of KMeans is presented. First, let's implement our\n`SimpleKMeans` estimator without the callback support.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "class SimpleKMeans(BaseEstimator):\n def __init__(self, n_clusters=6, n_iter=100, random_state=None):\n self.n_clusters = n_clusters\n self.n_iter = n_iter\n self.random_state = random_state # controls the centroids' initialization\n\n def _compute_labels(self, X):\n # Get the index of the closest centroid for each point in X.\n return np.argmin(euclidean_distances(X, self.cluster_centers_), axis=1)\n\n def fit(self, X, y=None):\n # `y` is not used but we need to declare it to adhere to scikit-learn's\n # estimators fit convention.\n\n # Input validation is a good practice in estimators, for more information about\n # it you can refer to\n # https://scikit-learn.org/stable/developers/develop.html#input-validation.\n X = validate_data(self, X)\n random_state = check_random_state(self.random_state)\n # Randomly initialize the centroids.\n self.cluster_centers_ = random_state.rand(self.n_clusters, X.shape[1])\n\n for i in range(self.n_iter):\n # The fit iterations consist in getting the cluster label of each data point\n # according to their closest centroid, and then updating the centroids as\n # the center of each cluster.\n labels = self._compute_labels(X)\n\n for k in range(self.n_clusters):\n # For each centroid, if its cluster is not empty, its coordinates are\n # updated with the coordinates of the cluster's center.\n if (labels == k).any():\n self.cluster_centers_[k] = X[labels == k].mean(axis=0)\n\n return self\n\n def predict(self, X):\n check_is_fitted(self)\n return self._compute_labels(X)\n\n def transform(self, X):\n check_is_fitted(self)\n return euclidean_distances(X, self.cluster_centers_)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Now let's add all the elements necessary to support callbacks.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "# First things first, the estimator must inherit from the `CallbackSupportMixin` class.\nclass SimpleKMeans(CallbackSupportMixin, BaseEstimator): # noqa: F811\n def __init__(self, n_clusters=6, n_iter=100, random_state=None):\n self.n_clusters = n_clusters\n self.n_iter = n_iter\n self.random_state = random_state\n\n def _compute_labels(self, X):\n return np.argmin(euclidean_distances(X, self.cluster_centers_), axis=1)\n\n # Then the `fit` function must be decorated with the `with_callbacks`\n # decorator, which takes care of the proper teardown of callbacks.\n @with_callbacks\n def fit(self, X, y=None):\n X = validate_data(self, X)\n random_state = check_random_state(self.random_state)\n # The `CallbackContext` object must be instantiated with the\n # `_init_callback_context` method provided by the mixin, which calls the\n # `setup` hooks of the callbacks. This context corresponds to the root task of\n # the fit function.\n callback_ctx = self._init_callback_context(\n task_name=\"fit\", max_subtasks=self.n_iter\n )\n # Then the callback context's `call_on_fit_task_begin` method must be called. It\n # will call all the callbacks' `on_fit_task_begin` hooks. The `estimator`\n # argument is mandatory and optional `kwargs` can be passed to provide extra\n # contextual information for the callbacks, for example here `X` and `y` are\n # passed. See the following note for more details on these extra `kwargs`.\n callback_ctx.call_on_fit_task_begin(estimator=self, X=X, y=y)\n\n self.cluster_centers_ = random_state.rand(self.n_clusters, X.shape[1])\n\n for i in range(self.n_iter):\n # For each sub-task of fit (here each iteration of the loop), a sub-context\n # must be created with the callback context's `subcontext` method.\n subcontext = callback_ctx.subcontext(task_name=\"fit iteration\")\n # The sub-context corresponds to a new sub-task, so its\n # `call_on_fit_task_begin` method must also be called.\n subcontext.call_on_fit_task_begin(estimator=self, X=X, y=y)\n\n labels = self._compute_labels(X)\n\n for k in range(self.n_clusters):\n if (labels == k).any():\n self.cluster_centers_[k] = X[labels == k].mean(axis=0)\n\n # After each sub-task, the `call_on_fit_task_end` method of its sub-context\n # must be called, also with `estimator` as a mandatory argument and optional\n # `kwargs`. It will call all the callbacks' `on_fit_task_end` hooks. Here\n # the extra `kwargs` contain a `reconstruction_attributes` callable,\n # which returns the necessary attributes to generate an estimator instance\n # ready to predict, as if the fit process just stopped at this step.\n if subcontext.call_on_fit_task_end(\n estimator=self,\n X=X,\n y=y,\n reconstruction_attributes=lambda: {\n \"cluster_centers_\": self.cluster_centers_,\n },\n ):\n # The `call_on_fit_task_end` method returns a boolean, which is set\n # to True if any of the callbacks' `on_fit_task_end` methods return\n # True. This enables the interruption of the `fit` process by the\n # callbacks, for example to implement early stopping. Thus the\n # `call_on_fit_task_end` method can be used in an `if` / `break` block\n # to enable such interruptions.\n break\n\n # After the root task of the fit function is done, the `call_on_fit_task_end`\n # method of its callback context must be called.\n callback_ctx.call_on_fit_task_end(\n estimator=self,\n X=X,\n y=y,\n reconstruction_attributes=lambda: {\n \"cluster_centers_\": self.cluster_centers_,\n },\n )\n\n # The callbacks' `teardown` hooks are called automatically in the decorator,\n # after fit finishes, even if it crashed.\n return self\n\n def predict(self, X):\n check_is_fitted(self)\n return self._compute_labels(X)\n\n def transform(self, X):\n check_is_fitted(self)\n return euclidean_distances(X, self.cluster_centers_)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "
See the documentation of the methods\n :meth:`~CallbackContext.call_on_fit_task_begin` and\n :meth:`~CallbackContext.call_on_fit_task_end` for the description of the `kwargs`\n they can accept. These `kwargs` are optional, but an estimator should provide all\n the ones it is capable of producing in each task to be compatible with a maximum\n number of callbacks.
A meta-estimator that supports callback can be used with sub-estimators that do\n not. In that case a warning is raised when trying to propagate the callbacks\n and the callbacks are ignored in the sub-estimator.