Repository: scikit-learn/scikit-learn
Pull request: MAINT Common parameter validation
This PR proposes a unified design for parameter validation across estimators, classes and functions.

The goal is to have a consistent way to raise an informative error message when a parameter does not have a valid type/value. Here's an example:
```py
>>> KMeans(init="wrong").fit(X)
ValueError: The 'init' parameter of KMeans must be a str among {'k-means++', 'random'}, a callable or an array-like. Got 'wrong' instead.
```

It's also meant to centralize all these checks in one place, i.e. being the first instruction of fit or of a function. Currently they can be spread throughout fit making it hard to follow and slow to fail. I also find that having all this boilerplate inside fit makes the actual interesting code of the algorithm hard to find and mixed up with non-relevant code.
In addition, these checks are currently often done for a small subset of the parameters and often not tested. And when tested, it's often spread inside several tests.

This PR only deals with non co-dependent types and values between parameters. For instance if a value of a parameter is valid only if some value of another parameter is set.

I propose to add to `BaseEstimator` a method `_validate_params` that performs validation for all parameters of estimators and a decorator `validate_params` for public functions. Validation is made against a dict `param_name: constraint` where constraint is a list of valid types/values.
```py
# param validation of an estimator
class SomeEstimator(BaseEstimator):
    _parameter_constraints = {
        "n_clusters": [Interval(Integral, 1, None, closed="left")],
        "init": [StrOptions(["k-means++", "random"]), callable, "array-like")],
        "tol": [Interval(Real, 0, None, closed="left")],
        "algorithm": [StrOptions(["lloyd", "elkan", "auto", "full"], deprecated={"auto", "full"})],
        "max_no_improvement": [None,  Interval(Integral, 0, None, closed="left")]
    }

    def fit(X, y):
        self._validate_params()
```
```py
# param validation of a function
@validate_params(
    {
        "n_clusters": [Interval(Integral, 1, None, closed="left")],
        "init": [StrOptions(["k-means++", "random"]), callable, "array-like")],
        "tol": [Interval(Real, 0, None, closed="left")],
        "algorithm": [StrOptions(["lloyd", "elkan", "auto", "full"], deprecated={"auto", "full"})],
        "max_no_improvement": [None,  Interval(Integral, 0, None, closed="left")]
    }
)
def some_func(n_clusters, init, tol, algorithm, max_no_improvement):
    ...
```

I also propose to add a new common test that makes sure this is done for all estimators (almost all of them being skipped right now).

closes https://github.com/scikit-learn/scikit-learn/issues/14721

Problem statement:
Use decorators for simple input validations
We have a lot of input validations on simple scalar inputs which can be handled with a decorator. It'd be less error prune and would simplify the code. Do we think that's a good idea?

Something like

```python
@validate_input('threshold', min=0, max=1, type=[float, int],
    err_message='the message goes here')
def f(threshold=0.9):
    ...
```

Hints from issue discussion:
I like this for functions. 

We also do a bunch of similar validation in `fit` which we would not be able to use this decorator... unless we do something clever.
Yeah I was hoping we could do that for methods too, haven't looked into how it'd work though.
@thomasjpfan the [`wrapt`](https://github.com/GrahamDumpleton/wrapt)  package has a nice solution for that, which would make you write decorators as:

```python

@wrapt.decorator
def universal(wrapped, instance, args, kwargs):
    if instance is None:
        if inspect.isclass(wrapped):
            # Decorator was applied to a class.
            return wrapped(*args, **kwargs)
        else:
            # Decorator was applied to a function or staticmethod.
            return wrapped(*args, **kwargs)
    else:
        if inspect.isclass(instance):
            # Decorator was applied to a classmethod.
            return wrapped(*args, **kwargs)
        else:
            # Decorator was applied to an instancemethod.
            return wrapped(*args, **kwargs)
```

So we could either vendor a part of that package, or have separate decorators for methods vs functions. Which one would you prefer?
We have often steered away from decorators etc because they look like magic
to people who learnt to code in their science degree. Just putting that out
there.

> We have often steered away from decorators etc because they look like magic
to people who learnt to code in their science degree. Just putting that out
there.

`if_delegate_has_method` together with `_IffHasAttrDescriptor` is quite magical.

I agree there is a line we need to walk. I think `decorators` are well known to most python developers. The core benefit of having a decorator like this, is that is standardizes the `ValueError`s and reduces lines of code to maintain in the simple cases.

There are a few downsides:

1. It is harder to understand for really new users of python.
2. It does not cover parameters that are dependent on each other.
 if_delegate_has_method needs to be magical. It requires an understanding
of descriptors and hasattr. I don't think its existence justifies
widespread use of decorators.

What do you think of having utility that checks the range and raises `ValueError`? This utility can be called something like: `_check_in_range` for numerical range checks. 
Definitely fine with that. I'm not particularly opposed to decorators, but
thought the point of readability ease needed to be raised.

This is in progress in https://github.com/scikit-learn/scikit-learn/pull/22722

Commit message: address comments

Patch to review:
diff --git a/sklearn/base.py b/sklearn/base.py
index 474380152028b..69c4ffad3dda2 100644
--- a/sklearn/base.py
+++ b/sklearn/base.py
@@ -25,8 +25,9 @@
 from .utils.validation import _check_feature_names_in
 from .utils.validation import _generate_get_feature_names_out
 from .utils.validation import check_is_fitted
-from .utils._estimator_html_repr import estimator_html_repr
 from .utils.validation import _get_feature_names
+from .utils._estimator_html_repr import estimator_html_repr
+from .utils._param_validation import validate_parameter_constraints
 
 
 def clone(estimator, *, safe=True):
@@ -601,6 +602,18 @@ def _validate_data(
 
         return out
 
+    def _validate_params(self):
+        """Validate types and values of constructor parameters
+
+        The expected type and values must be defined in the `_parameter_constraints`
+        class attribute, which is a dictionary `param_name: list of constraints`. See
+        the docstring of `validate_parameter_constraints` for a description of the
+        accepted constraints.
+        """
+        validate_parameter_constraints(
+            self._parameter_constraints, self.get_params(deep=False)
+        )
+
     @property
     def _repr_html_(self):
         """HTML representation of estimator.
diff --git a/sklearn/cluster/_kmeans.py b/sklearn/cluster/_kmeans.py
index ae55e4b58bfb1..eeb588f8393ae 100644
--- a/sklearn/cluster/_kmeans.py
+++ b/sklearn/cluster/_kmeans.py
@@ -12,6 +12,7 @@
 # License: BSD 3 clause
 
 from abc import ABC, abstractmethod
+from numbers import Integral, Real
 import warnings
 
 import numpy as np
@@ -34,6 +35,9 @@
 from ..utils import check_random_state
 from ..utils.validation import check_is_fitted, _check_sample_weight
 from ..utils.validation import _is_arraylike_not_scalar
+from ..utils._param_validation import Interval
+from ..utils._param_validation import StrOptions
+from ..utils._param_validation import validate_params
 from ..utils._openmp_helpers import _openmp_effective_n_threads
 from ..utils._readonly_array_wrapper import ReadonlyArrayWrapper
 from ..exceptions import ConvergenceWarning
@@ -55,6 +59,15 @@
 # Initialization heuristic
 
 
+@validate_params(
+    {
+        "X": ["array-like", "sparse matrix"],
+        "n_clusters": [Interval(Integral, 1, None, closed="left")],
+        "x_squared_norms": ["array-like", None],
+        "random_state": ["random_state"],
+        "n_local_trials": [Interval(Integral, 1, None, closed="left"), None],
+    }
+)
 def kmeans_plusplus(
     X, n_clusters, *, x_squared_norms=None, random_state=None, n_local_trials=None
 ):
@@ -114,7 +127,6 @@ def kmeans_plusplus(
     >>> indices
     array([4, 2])
     """
-
     # Check data
     check_array(X, accept_sparse="csr", dtype=[np.float64, np.float32])
 
@@ -135,12 +147,6 @@ def kmeans_plusplus(
             f"be equal to the length of n_samples {X.shape[0]}."
         )
 
-    if n_local_trials is not None and n_local_trials < 1:
-        raise ValueError(
-            f"n_local_trials is set to {n_local_trials} but should be an "
-            "integer value greater than zero."
-        )
-
     random_state = check_random_state(random_state)
 
     # Call private k-means++
@@ -786,6 +792,16 @@ class _BaseKMeans(
 ):
     """Base class for KMeans and MiniBatchKMeans"""
 
+    _parameter_constraints = {
+        "n_clusters": [Interval(Integral, 1, None, closed="left")],
+        "init": [StrOptions({"k-means++", "random"}), callable, "array-like"],
+        "n_init": [Interval(Integral, 1, None, closed="left")],
+        "max_iter": [Interval(Integral, 1, None, closed="left")],
+        "tol": [Interval(Real, 0, None, closed="left")],
+        "verbose": [Interval(Integral, 0, None, closed="left"), bool],
+        "random_state": ["random_state"],
+    }
+
     def __init__(
         self,
         n_clusters,
@@ -805,16 +821,7 @@ def __init__(
         self.verbose = verbose
         self.random_state = random_state
 
-    def _check_params(self, X):
-        # n_init
-        if self.n_init <= 0:
-            raise ValueError(f"n_init should be > 0, got {self.n_init} instead.")
-        self._n_init = self.n_init
-
-        # max_iter
-        if self.max_iter <= 0:
-            raise ValueError(f"max_iter should be > 0, got {self.max_iter} instead.")
-
+    def _check_params_vs_input(self, X):
         # n_clusters
         if X.shape[0] < self.n_clusters:
             raise ValueError(
@@ -825,16 +832,7 @@ def _check_params(self, X):
         self._tol = _tolerance(X, self.tol)
 
         # init
-        if not (
-            _is_arraylike_not_scalar(self.init)
-            or callable(self.init)
-            or (isinstance(self.init, str) and self.init in ["k-means++", "random"])
-        ):
-            raise ValueError(
-                "init should be either 'k-means++', 'random', an array-like or a "
-                f"callable, got '{self.init}' instead."
-            )
-
+        self._n_init = self.n_init
         if _is_arraylike_not_scalar(self.init) and self._n_init != 1:
             warnings.warn(
                 "Explicit initial center position passed: performing only"
@@ -1260,6 +1258,14 @@ class KMeans(_BaseKMeans):
            [ 1.,  2.]])
     """
 
+    _parameter_constraints = {
+        **_BaseKMeans._parameter_constraints,
+        "copy_x": [bool],
+        "algorithm": [
+            StrOptions({"lloyd", "elkan", "auto", "full"}, deprecated={"auto", "full"})
+        ],
+    }
+
     def __init__(
         self,
         n_clusters=8,
@@ -1286,15 +1292,8 @@ def __init__(
         self.copy_x = copy_x
         self.algorithm = algorithm
 
-    def _check_params(self, X):
-        super()._check_params(X)
-
-        # algorithm
-        if self.algorithm not in ("lloyd", "elkan", "auto", "full"):
-            raise ValueError(
-                "Algorithm must be either 'lloyd' or 'elkan', "
-                f"got {self.algorithm} instead."
-            )
+    def _check_params_vs_input(self, X):
+        super()._check_params_vs_input(X)
 
         self._algorithm = self.algorithm
         if self._algorithm in ("auto", "full"):
@@ -1347,6 +1346,8 @@ def fit(self, X, y=None, sample_weight=None):
         self : object
             Fitted estimator.
         """
+        self._validate_params()
+
         X = self._validate_data(
             X,
             accept_sparse="csr",
@@ -1356,7 +1357,8 @@ def fit(self, X, y=None, sample_weight=None):
             accept_large_sparse=False,
         )
 
-        self._check_params(X)
+        self._check_params_vs_input(X)
+
         random_state = check_random_state(self.random_state)
         sample_weight = _check_sample_weight(sample_weight, X, dtype=X.dtype)
         self._n_threads = _openmp_effective_n_threads()
@@ -1740,6 +1742,15 @@ class MiniBatchKMeans(_BaseKMeans):
     array([0, 1], dtype=int32)
     """
 
+    _parameter_constraints = {
+        **_BaseKMeans._parameter_constraints,
+        "batch_size": [Interval(Integral, 1, None, closed="left")],
+        "compute_labels": [bool],
+        "max_no_improvement": [Interval(Integral, 0, None, closed="left"), None],
+        "init_size": [Interval(Integral, 1, None, closed="left"), None],
+        "reassignment_ratio": [Interval(Real, 0, None, closed="left")],
+    }
+
     def __init__(
         self,
         n_clusters=8,
@@ -1773,26 +1784,12 @@ def __init__(
         self.init_size = init_size
         self.reassignment_ratio = reassignment_ratio
 
-    def _check_params(self, X):
-        super()._check_params(X)
-
-        # max_no_improvement
-        if self.max_no_improvement is not None and self.max_no_improvement < 0:
-            raise ValueError(
-                "max_no_improvement should be >= 0, got "
-                f"{self.max_no_improvement} instead."
-            )
+    def _check_params_vs_input(self, X):
+        super()._check_params_vs_input(X)
 
-        # batch_size
-        if self.batch_size <= 0:
-            raise ValueError(
-                f"batch_size should be > 0, got {self.batch_size} instead."
-            )
         self._batch_size = min(self.batch_size, X.shape[0])
 
         # init_size
-        if self.init_size is not None and self.init_size <= 0:
-            raise ValueError(f"init_size should be > 0, got {self.init_size} instead.")
         self._init_size = self.init_size
         if self._init_size is None:
             self._init_size = 3 * self._batch_size
@@ -1934,6 +1931,8 @@ def fit(self, X, y=None, sample_weight=None):
         self : object
             Fitted estimator.
         """
+        self._validate_params()
+
         X = self._validate_data(
             X,
             accept_sparse="csr",
@@ -1942,7 +1941,7 @@ def fit(self, X, y=None, sample_weight=None):
             accept_large_sparse=False,
         )
 
-        self._check_params(X)
+        self._check_params_vs_input(X)
         random_state = check_random_state(self.random_state)
         sample_weight = _check_sample_weight(sample_weight, X, dtype=X.dtype)
         self._n_threads = _openmp_effective_n_threads()
@@ -2091,6 +2090,9 @@ def partial_fit(self, X, y=None, sample_weight=None):
         """
         has_centers = hasattr(self, "cluster_centers_")
 
+        if not has_centers:
+            self._validate_params()
+
         X = self._validate_data(
             X,
             accept_sparse="csr",
@@ -2111,7 +2113,7 @@ def partial_fit(self, X, y=None, sample_weight=None):
 
         if not has_centers:
             # this instance has not been fitted yet (fit or partial_fit)
-            self._check_params(X)
+            self._check_params_vs_input(X)
             self._n_threads = _openmp_effective_n_threads()
 
             # Validate init array
diff --git a/sklearn/cluster/tests/test_k_means.py b/sklearn/cluster/tests/test_k_means.py
index 2a8e43eebb822..65051ce98c790 100644
--- a/sklearn/cluster/tests/test_k_means.py
+++ b/sklearn/cluster/tests/test_k_means.py
@@ -1047,8 +1047,6 @@ def test_sample_weight_unchanged(Estimator):
 @pytest.mark.parametrize(
     "param, match",
     [
-        ({"n_init": 0}, r"n_init should be > 0"),
-        ({"max_iter": 0}, r"max_iter should be > 0"),
         ({"n_clusters": n_samples + 1}, r"n_samples.* should be >= n_clusters"),
         (
             {"init": X[:2]},
@@ -1070,11 +1068,6 @@ def test_sample_weight_unchanged(Estimator):
             r"The shape of the initial centers .* does not match "
             r"the number of features of the data",
         ),
-        (
-            {"init": "wrong"},
-            r"init should be either 'k-means\+\+', 'random', "
-            r"an array-like or a callable",
-        ),
     ],
 )
 def test_wrong_params(Estimator, param, match):
@@ -1086,41 +1079,9 @@ def test_wrong_params(Estimator, param, match):
         km.set_params(**param).fit(X)
 
 
-@pytest.mark.parametrize(
-    "param, match",
-    [({"algorithm": "wrong"}, r"Algorithm must be either 'lloyd' or 'elkan'")],
-)
-def test_kmeans_wrong_params(param, match):
-    # Check that error are raised with clear error message when wrong values
-    # are passed for the KMeans specific parameters
-    with pytest.raises(ValueError, match=match):
-        KMeans(**param).fit(X)
-
-
-@pytest.mark.parametrize(
-    "param, match",
-    [
-        ({"max_no_improvement": -1}, r"max_no_improvement should be >= 0"),
-        ({"batch_size": -1}, r"batch_size should be > 0"),
-        ({"init_size": -1}, r"init_size should be > 0"),
-        ({"reassignment_ratio": -1}, r"reassignment_ratio should be >= 0"),
-    ],
-)
-def test_minibatch_kmeans_wrong_params(param, match):
-    # Check that error are raised with clear error message when wrong values
-    # are passed for the MiniBatchKMeans specific parameters
-    with pytest.raises(ValueError, match=match):
-        MiniBatchKMeans(**param).fit(X)
-
-
 @pytest.mark.parametrize(
     "param, match",
     [
-        (
-            {"n_local_trials": 0},
-            r"n_local_trials is set to 0 but should be an "
-            r"integer value greater than zero",
-        ),
         (
             {"x_squared_norms": X[:2]},
             r"The length of x_squared_norms .* should "
diff --git a/sklearn/tests/test_common.py b/sklearn/tests/test_common.py
index 251f0831fb380..f968382f9ac7b 100644
--- a/sklearn/tests/test_common.py
+++ b/sklearn/tests/test_common.py
@@ -51,6 +51,7 @@
     parametrize_with_checks,
     check_dataframe_column_names_consistency,
     check_n_features_in_after_fitting,
+    check_param_validation,
     check_transformer_get_feature_names_out,
     check_transformer_get_feature_names_out_pandas,
 )
@@ -444,3 +445,212 @@ def test_estimators_do_not_raise_errors_in_init_or_set_params(Estimator):
 
         # Also do does not raise
         est.set_params(**new_params)
+
+
+PARAM_VALIDATION_ESTIMATORS_TO_IGNORE = [
+    "ARDRegression",
+    "AdaBoostClassifier",
+    "AdaBoostRegressor",
+    "AdditiveChi2Sampler",
+    "AffinityPropagation",
+    "AgglomerativeClustering",
+    "BaggingClassifier",
+    "BaggingRegressor",
+    "BayesianGaussianMixture",
+    "BayesianRidge",
+    "BernoulliNB",
+    "BernoulliRBM",
+    "Binarizer",
+    "Birch",
+    "CCA",
+    "CalibratedClassifierCV",
+    "CategoricalNB",
+    "ClassifierChain",
+    "ComplementNB",
+    "CountVectorizer",
+    "DBSCAN",
+    "DecisionTreeClassifier",
+    "DecisionTreeRegressor",
+    "DictVectorizer",
+    "DictionaryLearning",
+    "DummyClassifier",
+    "DummyRegressor",
+    "ElasticNet",
+    "ElasticNetCV",
+    "EllipticEnvelope",
+    "EmpiricalCovariance",
+    "ExtraTreeClassifier",
+    "ExtraTreeRegressor",
+    "ExtraTreesClassifier",
+    "ExtraTreesRegressor",
+    "FactorAnalysis",
+    "FastICA",
+    "FeatureAgglomeration",
+    "FeatureHasher",
+    "FunctionTransformer",
+    "GammaRegressor",
+    "GaussianMixture",
+    "GaussianNB",
+    "GaussianProcessClassifier",
+    "GaussianProcessRegressor",
+    "GaussianRandomProjection",
+    "GenericUnivariateSelect",
+    "GradientBoostingClassifier",
+    "GradientBoostingRegressor",
+    "GraphicalLasso",
+    "GraphicalLassoCV",
+    "HashingVectorizer",
+    "HistGradientBoostingClassifier",
+    "HistGradientBoostingRegressor",
+    "HuberRegressor",
+    "IncrementalPCA",
+    "IsolationForest",
+    "Isomap",
+    "IsotonicRegression",
+    "IterativeImputer",
+    "KBinsDiscretizer",
+    "KNNImputer",
+    "KNeighborsClassifier",
+    "KNeighborsRegressor",
+    "KNeighborsTransformer",
+    "KernelDensity",
+    "KernelPCA",
+    "KernelRidge",
+    "LabelBinarizer",
+    "LabelPropagation",
+    "LabelSpreading",
+    "Lars",
+    "LarsCV",
+    "Lasso",
+    "LassoCV",
+    "LassoLars",
+    "LassoLarsCV",
+    "LassoLarsIC",
+    "LatentDirichletAllocation",
+    "LedoitWolf",
+    "LinearDiscriminantAnalysis",
+    "LinearRegression",
+    "LinearSVC",
+    "LinearSVR",
+    "LocalOutlierFactor",
+    "LocallyLinearEmbedding",
+    "LogisticRegression",
+    "LogisticRegressionCV",
+    "MDS",
+    "MLPClassifier",
+    "MLPRegressor",
+    "MaxAbsScaler",
+    "MeanShift",
+    "MinCovDet",
+    "MinMaxScaler",
+    "MiniBatchDictionaryLearning",
+    "MiniBatchSparsePCA",
+    "MissingIndicator",
+    "MultiLabelBinarizer",
+    "MultiOutputClassifier",
+    "MultiOutputRegressor",
+    "MultiTaskElasticNet",
+    "MultiTaskElasticNetCV",
+    "MultiTaskLasso",
+    "MultiTaskLassoCV",
+    "MultinomialNB",
+    "NMF",
+    "NearestCentroid",
+    "NearestNeighbors",
+    "NeighborhoodComponentsAnalysis",
+    "Normalizer",
+    "NuSVC",
+    "NuSVR",
+    "Nystroem",
+    "OAS",
+    "OPTICS",
+    "OneClassSVM",
+    "OneHotEncoder",
+    "OneVsOneClassifier",
+    "OneVsRestClassifier",
+    "OrdinalEncoder",
+    "OrthogonalMatchingPursuit",
+    "OrthogonalMatchingPursuitCV",
+    "OutputCodeClassifier",
+    "PCA",
+    "PLSCanonical",
+    "PLSRegression",
+    "PLSSVD",
+    "PassiveAggressiveClassifier",
+    "PassiveAggressiveRegressor",
+    "PatchExtractor",
+    "Perceptron",
+    "PoissonRegressor",
+    "PolynomialCountSketch",
+    "PolynomialFeatures",
+    "PowerTransformer",
+    "QuadraticDiscriminantAnalysis",
+    "QuantileRegressor",
+    "QuantileTransformer",
+    "RANSACRegressor",
+    "RBFSampler",
+    "RFE",
+    "RFECV",
+    "RadiusNeighborsClassifier",
+    "RadiusNeighborsRegressor",
+    "RadiusNeighborsTransformer",
+    "RandomForestClassifier",
+    "RandomForestRegressor",
+    "RandomTreesEmbedding",
+    "RegressorChain",
+    "Ridge",
+    "RidgeCV",
+    "RidgeClassifier",
+    "RidgeClassifierCV",
+    "RobustScaler",
+    "SGDClassifier",
+    "SGDOneClassSVM",
+    "SGDRegressor",
+    "SVC",
+    "SVR",
+    "SelectFdr",
+    "SelectFpr",
+    "SelectFromModel",
+    "SelectFwe",
+    "SelectKBest",
+    "SelectPercentile",
+    "SelfTrainingClassifier",
+    "SequentialFeatureSelector",
+    "ShrunkCovariance",
+    "SimpleImputer",
+    "SkewedChi2Sampler",
+    "SparsePCA",
+    "SparseRandomProjection",
+    "SpectralBiclustering",
+    "SpectralClustering",
+    "SpectralCoclustering",
+    "SpectralEmbedding",
+    "SplineTransformer",
+    "StackingClassifier",
+    "StackingRegressor",
+    "StandardScaler",
+    "TSNE",
+    "TfidfTransformer",
+    "TfidfVectorizer",
+    "TheilSenRegressor",
+    "TransformedTargetRegressor",
+    "TruncatedSVD",
+    "TweedieRegressor",
+    "VarianceThreshold",
+    "VotingClassifier",
+    "VotingRegressor",
+]
+
+
+@pytest.mark.parametrize(
+    "estimator", _tested_estimators(), ids=_get_check_estimator_ids
+)
+def test_check_param_validation(estimator):
+    name = estimator.__class__.__name__
+    if name in PARAM_VALIDATION_ESTIMATORS_TO_IGNORE:
+        pytest.skip(
+            f"Skipping check_param_validation for {name}: Does not use the "
+            "appropriate API for parameter validation yet."
+        )
+    _set_checking_parameters(estimator)
+    check_param_validation(name, estimator)
diff --git a/sklearn/utils/_param_validation.py b/sklearn/utils/_param_validation.py
new file mode 100644
index 0000000000000..9aa8cef7d76a7
--- /dev/null
+++ b/sklearn/utils/_param_validation.py
@@ -0,0 +1,421 @@
+from abc import ABC
+from abc import abstractmethod
+import functools
+from inspect import signature
+from numbers import Integral
+from numbers import Real
+import operator
+
+import numpy as np
+from scipy.sparse import issparse
+
+from .validation import _is_arraylike_not_scalar
+
+
+def validate_parameter_constraints(parameter_constraints, params):
+    """Validate types and values of given parameters.
+
+    Parameters
+    ----------
+    parameter_constraints : dict
+        A dictionary `param_name: list of constraints`. A parameter is valid if it
+        satisfies one of the constraints from the list. Constraints can be:
+        - an Interval object, representing a continuous or discrete range of numbers
+        - the string "array-like"
+        - the string "sparse matrix"
+        - callable
+        - None, meaning that None is a valid value for the parameter
+        - any type, meaning that any instance of this type is valid
+        - a StrOptions object, representing a set of strings
+
+    params : dict
+        A dictionary `param_name: param_value`. The parameters to validate against the
+        constraints.
+    """
+    for param_name, constraints in parameter_constraints.items():
+
+        param_val = params[param_name]
+        constraints = [make_constraint(constraint) for constraint in constraints]
+
+        for constraint in constraints:
+            if constraint.is_satisfied_by(param_val):
+                # this constraint is satisfied, no need to check further.
+                break
+        else:
+            # No constraint is satisfied, raise with an informative message.
+            if len(constraints) == 1:
+                constraints_str = f"{constraints[0]}"
+            else:
+                constraints_str = (
+                    f"{', '.join([str(c) for c in constraints[:-1]])} or"
+                    f" {constraints[-1]}"
+                )
+
+            raise ValueError(
+                f"{param_name} must be {constraints_str}. Got {param_val!r} instead."
+            )
+
+
+def make_constraint(constraint):
+    """Convert the constraint into the appropriate Constraint object.
+
+    Parameters
+    ----------
+    constraint : object
+        The constraint to convert.
+
+    Returns
+    -------
+    constraint : instance of _Constraint
+        The converted constraint.
+    """
+    if isinstance(constraint, str) and constraint == "array-like":
+        return _ArrayLikes()
+    if isinstance(constraint, str) and constraint == "sparse matrix":
+        return _SparseMatrices()
+    if isinstance(constraint, str) and constraint == "random_state":
+        return _RandomStates()
+    if constraint is callable:
+        return _Callables()
+    if constraint is None:
+        return _NoneConstraint()
+    if isinstance(constraint, type):
+        return _InstancesOf(constraint)
+    if isinstance(constraint, (Interval, StrOptions)):
+        return constraint
+    raise ValueError(f"Unknown constraint type: {constraint}")
+
+
+def validate_params(parameter_constraints):
+    """Decorator to validate types and values of functions and methods.
+
+    Parameters
+    ----------
+    parameter_constraints : dict
+        A dictionary `param_name: list of constraints`. See the docstring of
+        `validate_parameter_constraints` for a description of the accepted constraints.
+
+    Returns
+    -------
+    decorated_function : function or method
+        The decorated function.
+    """
+
+    def decorator(func):
+        @functools.wraps(func)
+        def wrapper(*args, **kwargs):
+            sig_params = [
+                p
+                for p in signature(func).parameters.values()
+                if p.kind not in (p.VAR_KEYWORD, p.VAR_POSITIONAL)
+            ]
+
+            # combine the signature and the actual args and kwargs to build a map
+            # param_name: param_value
+            params = {}
+
+            # First, the positional arguments for which we need to parse the signature
+            # to recover their names. The signature object holds the parameters in the
+            # same order as in the function definition
+            for val, param in zip(args, sig_params):
+                # ignore self for methods
+                if param.name == "self":
+                    continue
+
+                params[param.name] = val
+
+            # Then, the keyword arguments which already hold both the name and the value
+            for name, val in kwargs.items():
+                params[name] = val
+
+            # Finally, the unset parameters that have a default (they are not in args
+            # or kwargs)
+            for param in [p for p in sig_params if p.default is not p.empty]:
+                if param.name not in params:
+                    params[param.name] = param.default
+
+            validate_parameter_constraints(parameter_constraints, params)
+            return func(*args, **kwargs)
+
+        return wrapper
+
+    return decorator
+
+
+class _Constraint(ABC):
+    """Base class for the constraint objects."""
+
+    @abstractmethod
+    def is_satisfied_by(self, val):
+        """Whether or not a value satisfies the constraint.
+
+        Parameters
+        ----------
+        val : object
+            The value to check.
+
+        Returns
+        -------
+        is_satisfied : bool
+            Whether or not the constraint is satisfied by this value.
+        """
+
+    @abstractmethod
+    def __str__(self):
+        """A human readable representational string of the constraint."""
+
+
+class _InstancesOf(_Constraint):
+    """Constraint representing instances of a given type.
+
+    Parameters
+    ----------
+    type : type
+        The valid type.
+    """
+
+    def __init__(self, type):
+        self.type = type
+
+    def _type_name(self, t):
+        """Convert type into human readable string."""
+        module = t.__module__
+        qualname = t.__qualname__
+        if module == "builtins":
+            return qualname
+        elif t == Real:
+            return "float"
+        elif t == Integral:
+            return "int"
+        return f"{module}.{qualname}"
+
+    def is_satisfied_by(self, val):
+        return isinstance(val, self.type)
+
+    def __str__(self):
+        return f"an instance of {self._type_name(self.type)!r}"
+
+
+class _NoneConstraint(_Constraint):
+    """Constraint representing the None singleton."""
+
+    def is_satisfied_by(self, val):
+        return val is None
+
+    def __str__(self):
+        return "None"
+
+
+class StrOptions(_Constraint):
+    """Constraint representing a set of strings.
+
+    Parameters
+    ----------
+    options : set of str
+        The set of valid strings.
+
+    deprecated : set of str or None, default=None
+        A subset of the `options` to mark as deprecated in the repr of the constraint.
+    """
+
+    @validate_params({"options": [set], "deprecated": [set, None]})
+    def __init__(self, options, deprecated=None):
+        self.options = options
+        self.deprecated = deprecated or {}
+
+    def is_satisfied_by(self, val):
+        return isinstance(val, str) and val in self.options
+
+    def _mark_if_deprecated(self, option):
+        """Add a deprecated mark to an option if needed."""
+        option_str = f"{option!r}"
+        if option in self.deprecated:
+            option_str = f"{option_str} (deprecated)"
+        return option_str
+
+    def __str__(self):
+        options_str = (
+            f"{', '.join([self._mark_if_deprecated(o) for o in self.options])}"
+        )
+        return f"a str among {{{options_str}}}"
+
+
+class Interval(_Constraint):
+    """Constraint representing an typed interval.
+
+    Parameters
+    ----------
+    type : {numbers.Integral, numbers.Real}
+        The set of numbers in which to set the interval.
+
+    left : float or int or None
+        The left bound of the interval. None means left bound is -∞.
+
+    right : float, int or None
+        The right bound of the interval. None means right bound is +∞.
+
+    closed : {"left", "right", "both", "neither"}
+        Whether the interval is open or closed. Possible choices are:
+
+        - `"left"`: the interval is closed on the left and open on the right.
+          It is equivalent to the interval `[ left, right )`.
+        - `"right"`: the interval is closed on the right and open on the left.
+          It is equivalent to the interval `( left, right ]`.
+        - `"both"`: the interval is closed.
+          It is equivalent to the interval `[ left, right ]`.
+        - `"neither"`: the interval is open.
+          It is equivalent to the interval `( left, right )`.
+
+    Notes
+    -----
+    Setting a bound to `None` and setting the interval closed is valid. For instance,
+    strictly speaking, `Interval(0, None, closed="both")` corresponds to
+    `[0, +∞) U {+∞}`.
+    """
+
+    @validate_params(
+        {
+            "type": [type],
+            "left": [Integral, Real, None],
+            "right": [Integral, Real, None],
+            "closed": [StrOptions({"left", "right", "both", "neither"})],
+        }
+    )
+    def __init__(self, type, left, right, *, closed):
+        self.type = type
+        self.left = left
+        self.right = right
+        self.closed = closed
+
+        self._check_params()
+
+    def _check_params(self):
+        if self.type is Integral:
+            suffix = "for an interval over the integers."
+            if self.left is not None and not isinstance(self.left, Integral):
+                raise TypeError(f"Expecting left to be an int {suffix}")
+            if self.right is not None and not isinstance(self.right, Integral):
+                raise TypeError(f"Expecting right to be an int {suffix}")
+            if self.left is None and self.closed in ("left", "both"):
+                raise ValueError(
+                    f"left can't be None when closed == {self.closed} {suffix}"
+                )
+            if self.right is None and self.closed in ("right", "both"):
+                raise ValueError(
+                    f"right can't be None when closed == {self.closed} {suffix}"
+                )
+
+        if self.right is not None and self.left is not None and self.right <= self.left:
+            raise ValueError(
+                f"right can't be less than left. Got left={self.left} and "
+                f"right={self.right}"
+            )
+
+    def __contains__(self, val):
+        left_cmp = operator.lt if self.closed in ("left", "both") else operator.le
+        right_cmp = operator.gt if self.closed in ("right", "both") else operator.ge
+
+        if self.left is not None and left_cmp(val, self.left):
+            return False
+        if self.right is not None and right_cmp(val, self.right):
+            return False
+        return True
+
+    def is_satisfied_by(self, val):
+        if not isinstance(val, self.type):
+            return False
+
+        return val in self
+
+    def __str__(self):
+        type_str = "an int" if self.type is Integral else "a float"
+        left_bracket = "[" if self.closed in ("left", "both") else "("
+        left_bound = "-inf" if self.left is None else self.left
+        right_bound = "inf" if self.right is None else self.right
+        right_bracket = "]" if self.closed in ("right", "both") else ")"
+        return (
+            f"{type_str} in the range "
+            f"{left_bracket}{left_bound}, {right_bound}{right_bracket}"
+        )
+
+
+class _ArrayLikes(_Constraint):
+    """Constraint representing array-likes"""
+
+    def is_satisfied_by(self, val):
+        return _is_arraylike_not_scalar(val)
+
+    def __str__(self):
+        return "an array-like"
+
+
+class _SparseMatrices(_Constraint):
+    """Constraint representing sparse matrices."""
+
+    def is_satisfied_by(self, val):
+        return issparse(val)
+
+    def __str__(self):
+        return "a sparse matrix"
+
+
+class _Callables(_Constraint):
+    """Constraint representing callables."""
+
+    def is_satisfied_by(self, val):
+        return callable(val)
+
+    def __str__(self):
+        return "a callable"
+
+
+class _RandomStates(_Constraint):
+    """Constraint representing random states.
+
+    Convenience class for
+    [Interval(Integral, 0, 2**32 - 1, closed="both"), np.random.RandomState, None]
+    """
+
+    def __init__(self):
+        self._constraints = [
+            Interval(Integral, 0, 2**32 - 1, closed="both"),
+            _InstancesOf(np.random.RandomState),
+            _NoneConstraint(),
+        ]
+
+    def is_satisfied_by(self, val):
+        return any(c.is_satisfied_by(val) for c in self._constraints)
+
+    def __str__(self):
+        return (
+            f"{', '.join([repr(c) for c in self._constraints[:-1]])} or"
+            f" {self._constraints[-1]}"
+        )
+
+
+def generate_invalid_param_val(constraint):
+    """Return a value that does not satisfy the constraint.
+
+    Parameters
+    ----------
+    constraint : Constraint
+        The constraint to generate a value for.
+
+    Returns
+    -------
+    val : object
+        A value that does not satisfy the constraint.
+    """
+    if isinstance(constraint, StrOptions):
+        return f"not {' or'.join(constraint.options)}"
+    elif isinstance(constraint, Interval):
+        interval = constraint
+        if interval.left is None and interval.right is None:
+            raise NotImplementedError
+
+        if interval.left is not None:
+            return interval.left - 1
+        else:
+            return interval.right + 1
+    else:
+        raise NotImplementedError
diff --git a/sklearn/utils/estimator_checks.py b/sklearn/utils/estimator_checks.py
index 9af8f41f88624..0ec6c7cb491e4 100644
--- a/sklearn/utils/estimator_checks.py
+++ b/sklearn/utils/estimator_checks.py
@@ -54,6 +54,8 @@
 from ..metrics.pairwise import rbf_kernel, linear_kernel, pairwise_distances
 from ..utils.fixes import threadpool_info
 from ..utils.validation import check_is_fitted
+from ..utils._param_validation import make_constraint
+from ..utils._param_validation import generate_invalid_param_val
 
 from . import shuffle
 from ._tags import (
@@ -4012,3 +4014,53 @@ def check_transformer_get_feature_names_out_pandas(name, transformer_orig):
     assert (
         len(feature_names_out_default) == n_features_out
     ), f"Expected {n_features_out} feature names, got {len(feature_names_out_default)}"
+
+
+def check_param_validation(name, estimator_orig):
+    # Check that an informative error is raised when the value of a constructor
+    # parameter does not have an appropriate type or value.
+    rng = np.random.RandomState(0)
+    X = rng.uniform(size=(20, 5))
+    X = _pairwise_estimator_convert_X(X, estimator_orig)
+    y = rng.randint(0, 2, size=20)
+    y = _enforce_estimator_tags_y(estimator_orig, y)
+
+    # this object does not have a valid type for sure for all params
+    param_with_bad_type = type("BadType", (), {})()
+
+    fit_methods = ["fit", "partial_fit", "fit_transform", "fit_predict"]
+    methods = [method for method in fit_methods if hasattr(estimator_orig, method)]
+
+    for param_name in estimator_orig.get_params(deep=False):
+        match = rf"{param_name} must be .* Got .* instead."
+        err_msg = (
+            f"{name} does not raise an informative error message when the "
+            f"parameter {param_name} does not have a valid type or value."
+        )
+
+        estimator = clone(estimator_orig)
+
+        # First, check that the error is raised if param doesn't match any valid type.
+        estimator.set_params(**{param_name: param_with_bad_type})
+
+        for method in methods:
+            with raises(ValueError, match=match, err_msg=err_msg):
+                getattr(estimator, method)(X, y)
+
+        # Then, for constraints that are more than a type constraint, check that the
+        # error is raised if param does match a valid type but does not match any valid
+        # value for this type.
+        constraints = estimator_orig._parameter_constraints[param_name]
+        constraints = [make_constraint(constraint) for constraint in constraints]
+
+        for constraint in constraints:
+            try:
+                bad_value = generate_invalid_param_val(constraint)
+            except NotImplementedError:
+                continue
+
+            estimator.set_params(**{param_name: bad_value})
+
+            for method in methods:
+                with raises(ValueError, match=match, err_msg=err_msg):
+                    getattr(estimator, method)(X, y)
diff --git a/sklearn/utils/tests/test_param_validation.py b/sklearn/utils/tests/test_param_validation.py
new file mode 100644
index 0000000000000..bd9a51ea4e771
--- /dev/null
+++ b/sklearn/utils/tests/test_param_validation.py
@@ -0,0 +1,175 @@
+from numbers import Integral, Real
+
+import numpy as np
+from scipy.sparse import csr_matrix
+import pytest
+
+from sklearn.utils._param_validation import Interval
+from sklearn.utils._param_validation import StrOptions
+from sklearn.utils._param_validation import _ArrayLikes
+from sklearn.utils._param_validation import _Callables
+from sklearn.utils._param_validation import _InstancesOf
+from sklearn.utils._param_validation import _NoneConstraint
+from sklearn.utils._param_validation import _RandomStates
+from sklearn.utils._param_validation import _SparseMatrices
+from sklearn.utils._param_validation import make_constraint
+from sklearn.utils._param_validation import generate_invalid_param_val
+
+
+@pytest.mark.parametrize("interval_type", [Integral, Real])
+def test_interval_range(interval_type):
+    """Check the range of values depending on closed."""
+    interval = Interval(interval_type, -2, 2, closed="left")
+    assert -2 in interval and 2 not in interval
+
+    interval = Interval(interval_type, -2, 2, closed="right")
+    assert -2 not in interval and 2 in interval
+
+    interval = Interval(interval_type, -2, 2, closed="both")
+    assert -2 in interval and 2 in interval
+
+    interval = Interval(interval_type, -2, 2, closed="neither")
+    assert -2 not in interval and 2 not in interval
+
+
+def test_interval_inf_in_bounds():
+    """Check that inf is included if a bound is closed and set to None.
+
+    Only valid for real intervals.
+    """
+    interval = Interval(Real, 0, None, closed="right")
+    assert np.inf in interval
+
+    interval = Interval(Real, None, 0, closed="left")
+    assert -np.inf in interval
+
+
+@pytest.mark.parametrize(
+    "params, error, match",
+    [
+        (
+            {"type": Integral, "left": 1.0, "right": 2, "closed": "both"},
+            TypeError,
+            r"Expecting left to be an int for an interval over the integers",
+        ),
+        (
+            {"type": Integral, "left": 1, "right": 2.0, "closed": "neither"},
+            TypeError,
+            "Expecting right to be an int for an interval over the integers",
+        ),
+        (
+            {"type": Integral, "left": None, "right": 0, "closed": "left"},
+            ValueError,
+            r"left can't be None when closed == left",
+        ),
+        (
+            {"type": Integral, "left": 0, "right": None, "closed": "right"},
+            ValueError,
+            r"right can't be None when closed == right",
+        ),
+        (
+            {"type": Integral, "left": 1, "right": -1, "closed": "both"},
+            ValueError,
+            r"right can't be less than left",
+        ),
+    ],
+)
+def test_interval_errors(params, error, match):
+    """Check that informative errors are raised for invalid combination of parameters"""
+    with pytest.raises(error, match=match):
+        Interval(**params)
+
+
+def test_stroptions():
+    """Sanity check for the StrOptions constraint"""
+    options = StrOptions({"a", "b", "c"}, deprecated={"c"})
+    assert options.is_satisfied_by("a")
+    assert options.is_satisfied_by("c")
+    assert not options.is_satisfied_by("d")
+
+    assert "'c' (deprecated)" in str(options)
+
+
+@pytest.mark.parametrize(
+    "constraint",
+    [
+        Interval(Real, None, 0, closed="left"),
+        Interval(Real, 0, None, closed="left"),
+        StrOptions({"a", "b", "c"}),
+    ],
+)
+def test_generate_invalid_param_val(constraint):
+    """Check that the value generated does not satisfy the constraint"""
+    bad_value = generate_invalid_param_val(constraint)
+    assert not constraint.is_satisfied_by(bad_value)
+
+
+@pytest.mark.parametrize(
+    "constraint",
+    [
+        _ArrayLikes,
+        _Callables,
+        _InstancesOf,
+        _NoneConstraint,
+        _RandomStates,
+        _SparseMatrices,
+    ],
+)
+def test_generate_invalid_param_val_not_error(constraint):
+    """Check that the value generated does not satisfy the constraint"""
+    with pytest.raises(NotImplementedError):
+        generate_invalid_param_val(constraint)
+
+
+# a class to test the _InstancesOf constraint
+class _SomeClass:
+    pass
+
+
+@pytest.mark.parametrize(
+    "constraint_declaration, value",
+    [
+        (Interval(Real, 0, 1, closed="both"), 0.42),
+        (Interval(Integral, 0, None, closed="neither"), 42),
+        (StrOptions({"a", "b", "c"}), "b"),
+        (callable, lambda x: x + 1),
+        (None, None),
+        ("array-like", [[1, 2], [3, 4]]),
+        ("array-like", np.array([[1, 2], [3, 4]])),
+        ("sparse matrix", csr_matrix([[1, 2], [3, 4]])),
+        ("random_state", 0),
+        ("random_state", np.random.RandomState(0)),
+        ("random_state", None),
+        (_SomeClass, _SomeClass()),
+        (int, 1),
+        (Real, 0.5),
+    ],
+)
+def test_is_satisified_by(constraint_declaration, value):
+    """Sanity check for the is_satisfied_by method"""
+    constraint = make_constraint(constraint_declaration)
+    assert constraint.is_satisfied_by(value)
+
+
+@pytest.mark.parametrize(
+    "constraint_declaration, expected_constraint_class",
+    [
+        (Interval(Real, 0, 1, closed="both"), Interval),
+        (StrOptions({"option1", "option2"}), StrOptions),
+        ("array-like", _ArrayLikes),
+        ("sparse matrix", _SparseMatrices),
+        ("random_state", _RandomStates),
+        (None, _NoneConstraint),
+        (callable, _Callables),
+        (int, _InstancesOf),
+    ],
+)
+def test_make_constraint(constraint_declaration, expected_constraint_class):
+    """Check that make_constraint dispaches to the appropriate constraint class"""
+    constraint = make_constraint(constraint_declaration)
+    assert constraint.__class__ is expected_constraint_class
+
+
+def test_make_constraint_unknown():
+    with pytest.raises(ValueError, match="Unknown constraint"):
+        make_constraint("not a valid constraint")


Code review comments:
diff_hunk: @@ -0,0 +1,421 @@
+from abc import ABC
+from abc import abstractmethod
+import functools
+from inspect import signature
+from numbers import Integral
+from numbers import Real
+import operator
+
+import numpy as np
+from scipy.sparse import issparse
+
+from .validation import _is_arraylike_not_scalar
+
+
+def validate_parameter_constraints(parameter_constraints, params):
+    """Validate types and values of given parameters.
+
+    Parameters
+    ----------
+    parameter_constraints : dict
+        A dictionary `param_name: list of constraints`. A parameter is valid if it
+        satisfies one of the constraints from the list. Constraints can be:
+        - an Interval object, representing a continuous or discrete range of numbers
+        - the string "array-like"
+        - the string "sparse matrix"
+        - callable
+        - None, meaning that None is a valid value for the parameter
+        - any type, meaning that any instance of this type is valid
+        - a StrOptions object, representing a set of strings
+
+    params : dict
+        A dictionary `param_name: param_value`. The parameters to validate against the
+        constraints.
+    """
+    for param_name, constraints in parameter_constraints.items():
+
+        param_val = params[param_name]
+        constraints = [make_constraint(constraint) for constraint in constraints]
+
+        for constraint in constraints:
+            if constraint.is_satisfied_by(param_val):
+                # this constraint is satisfied, no need to check further.
+                break
+        else:
+            # No constraint is satisfied, raise with an informative message.
+            if len(constraints) == 1:
+                constraints_str = f"{constraints[0]}"
+            else:
+                constraints_str = (
+                    f"{', '.join([str(c) for c in constraints[:-1]])} or"
+                    f" {constraints[-1]}"
+                )
+
+            raise ValueError(
+                f"{param_name} must be {constraints_str}. Got {param_val!r} instead."
+            )
+
+
+def make_constraint(constraint):
+    """Convert the constraint into the appropriate Constraint object.
+
+    Parameters
+    ----------
+    constraint : object
+        The constraint to convert.
+
+    Returns
+    -------
+    constraint : instance of _Constraint
+        The converted constraint.
+    """
+    if isinstance(constraint, str) and constraint == "array-like":
+        return _ArrayLikes()
+    if isinstance(constraint, str) and constraint == "sparse matrix":
+        return _SparseMatrices()
+    if isinstance(constraint, str) and constraint == "random_state":
+        return _RandomStates()
+    if constraint is callable:
+        return _Callables()
+    if constraint is None:
+        return _NoneConstraint()
+    if isinstance(constraint, type):
+        return _InstancesOf(constraint)
+    if isinstance(constraint, (Interval, StrOptions)):
+        return constraint
+    raise ValueError(f"Unknown constraint type: {constraint}")
+
+
+def validate_params(parameter_constraints):
+    """Decorator to validate types and values of functions and methods.
+
+    Parameters
+    ----------
+    parameter_constraints : dict
+        A dictionary `param_name: list of constraints`. See the docstring of
+        `validate_parameter_constraints` for a description of the accepted constraints.
+
+    Returns
+    -------
+    decorated_function : function or method
+        The decorated function.
+    """
+
+    def decorator(func):
+        @functools.wraps(func)
+        def wrapper(*args, **kwargs):
+            sig_params = [
+                p
+                for p in signature(func).parameters.values()
+                if p.kind not in (p.VAR_KEYWORD, p.VAR_POSITIONAL)
+            ]
+
+            # combine the signature and the actual args and kwargs to build a map
+            # param_name: param_value
+            params = {}
+
+            # First, the positional arguments for which we need to parse the signature
+            # to recover their names. The signature object holds the parameters in the
+            # same order as in the function definition
+            for val, param in zip(args, sig_params):
+                # ignore self for methods
+                if param.name == "self":
+                    continue
+
+                params[param.name] = val
+
+            # Then, the keyword arguments which already hold both the name and the value
+            for name, val in kwargs.items():
+                params[name] = val
+
+            # Finally, the unset parameters that have a default (they are not in args
+            # or kwargs)
+            for param in [p for p in sig_params if p.default is not p.empty]:
+                if param.name not in params:
+                    params[param.name] = param.default
+
+            validate_parameter_constraints(parameter_constraints, params)
+            return func(*args, **kwargs)
+
+        return wrapper
+
+    return decorator
+
+
+class _Constraint(ABC):
+    """Base class for the constraint objects."""
+
+    @abstractmethod
+    def is_satisfied_by(self, val):
+        """Whether or not a value satisfies the constraint.
+
+        Parameters
+        ----------
+        val : object
+            The value to check.
+
+        Returns
+        -------
+        is_satisfied : bool
+            Whether or not the constraint is satisfied by this value.
+        """
+
+    @abstractmethod
+    def __str__(self):
+        """A human readable representational string of the constraint."""
+
+
+class _InstancesOf(_Constraint):
+    """Constraint representing instances of a given type.
+
+    Parameters
+    ----------
+    type : type
+        The valid type.
+    """
+
+    def __init__(self, type):
+        self.type = type
+
+    def _type_name(self, t):
+        """Convert type into human readable string."""
+        module = t.__module__
+        qualname = t.__qualname__
+        if module == "builtins":
+            return qualname
+        elif t == Real:
+            return "float"
+        elif t == Integral:
+            return "int"
+        return f"{module}.{qualname}"
+
+    def is_satisfied_by(self, val):
+        return isinstance(val, self.type)
+
+    def __str__(self):
+        return f"an instance of {self._type_name(self.type)!r}"
+
+
+class _NoneConstraint(_Constraint):
+    """Constraint representing the None singleton."""
+
+    def is_satisfied_by(self, val):
+        return val is None
+
+    def __str__(self):
+        return "None"
+
+
+class StrOptions(_Constraint):
+    """Constraint representing a set of strings.
+
+    Parameters
+    ----------
+    options : set of str
+        The set of valid strings.
+
+    deprecated : set of str or None, default=None
+        A subset of the `options` to mark as deprecated in the repr of the constraint.
+    """
+
+    @validate_params({"options": [set], "deprecated": [set, None]})
+    def __init__(self, options, deprecated=None):
+        self.options = options
+        self.deprecated = deprecated or {}
+
+    def is_satisfied_by(self, val):
+        return isinstance(val, str) and val in self.options
+
+    def _mark_if_deprecated(self, option):
+        """Add a deprecated mark to an option if needed."""
+        option_str = f"{option!r}"
+        if option in self.deprecated:
+            option_str = f"{option_str} (deprecated)"
+        return option_str
+
+    def __str__(self):
+        options_str = (
+            f"{', '.join([self._mark_if_deprecated(o) for o in self.options])}"
+        )
+        return f"a str among {{{options_str}}}"
+
+
+class Interval(_Constraint):
+    """Constraint representing an typed interval.
+
+    Parameters
+    ----------
+    type : {numbers.Integral, numbers.Real}
+        The set of numbers in which to set the interval.
+
+    left : float or int or None
+        The left bound of the interval. None means left bound is -∞.
+
+    right : float, int or None
+        The right bound of the interval. None means right bound is +∞.
+
+    closed : {"left", "right", "both", "neither"}
+        Whether the interval is open or closed. Possible choices are:
+
+        - `"left"`: the interval is closed on the left and open on the right.
+          It is equivalent to the interval `[ left, right )`.
+        - `"right"`: the interval is closed on the right and open on the left.
+          It is equivalent to the interval `( left, right ]`.
+        - `"both"`: the interval is closed.
+          It is equivalent to the interval `[ left, right ]`.
+        - `"neither"`: the interval is open.
+          It is equivalent to the interval `( left, right )`.
+
+    Notes
+    -----
+    Setting a bound to `None` and setting the interval closed is valid. For instance,
+    strictly speaking, `Interval(0, None, closed="both")` corresponds to
+    `[0, +∞) U {+∞}`.
+    """
+
+    @validate_params(
+        {
+            "type": [type],
+            "left": [Integral, Real, None],
+            "right": [Integral, Real, None],
+            "closed": [StrOptions({"left", "right", "both", "neither"})],
+        }
+    )
+    def __init__(self, type, left, right, *, closed):
+        self.type = type
+        self.left = left
+        self.right = right
+        self.closed = closed
+
+        self._check_params()
+
+    def _check_params(self):
+        if self.type is Integral:
+            suffix = "for an interval over the integers."
+            if self.left is not None and not isinstance(self.left, Integral):
+                raise TypeError(f"Expecting left to be an int {suffix}")
+            if self.right is not None and not isinstance(self.right, Integral):
+                raise TypeError(f"Expecting right to be an int {suffix}")
+            if self.left is None and self.closed in ("left", "both"):
+                raise ValueError(
+                    f"left can't be None when closed == {self.closed} {suffix}"
+                )
+            if self.right is None and self.closed in ("right", "both"):
+                raise ValueError(
+                    f"right can't be None when closed == {self.closed} {suffix}"
+                )
+
+        if self.right is not None and self.left is not None and self.right <= self.left:
+            raise ValueError(
+                f"right can't be less than left. Got left={self.left} and "
+                f"right={self.right}"
+            )
+
+    def __contains__(self, val):
+        left_cmp = operator.lt if self.closed in ("left", "both") else operator.le
+        right_cmp = operator.gt if self.closed in ("right", "both") else operator.ge
+
+        if self.left is not None and left_cmp(val, self.left):
+            return False
+        if self.right is not None and right_cmp(val, self.right):
+            return False
+        return True
+
+    def is_satisfied_by(self, val):
+        if not isinstance(val, self.type):
+            return False
+
+        return val in self
+
+    def __str__(self):
+        type_str = "an int" if self.type is Integral else "a float"
+        left_bracket = "[" if self.closed in ("left", "both") else "("
+        left_bound = "-inf" if self.left is None else self.left
+        right_bound = "inf" if self.right is None else self.right
+        right_bracket = "]" if self.closed in ("right", "both") else ")"
+        return (
+            f"{type_str} in the range "
+            f"{left_bracket}{left_bound}, {right_bound}{right_bracket}"
+        )
+
+
+class _ArrayLikes(_Constraint):
+    """Constraint representing array-likes"""
+
+    def is_satisfied_by(self, val):
+        return _is_arraylike_not_scalar(val)
+
+    def __str__(self):
+        return "an array-like"
+
+
+class _SparseMatrices(_Constraint):
+    """Constraint representing sparse matrices."""
+
+    def is_satisfied_by(self, val):
+        return issparse(val)
+
+    def __str__(self):
+        return "a sparse matrix"
+
+
+class _Callables(_Constraint):
+    """Constraint representing callables."""
+
+    def is_satisfied_by(self, val):
+        return callable(val)
+
+    def __str__(self):
+        return "a callable"
+
+
+class _RandomStates(_Constraint):
+    """Constraint representing random states.
+
+    Convenience class for
+    [Interval(Integral, 0, 2**32 - 1, closed="both"), np.random.RandomState, None]
+    """
+
+    def __init__(self):
+        self._constraints = [
+            Interval(Integral, 0, 2**32 - 1, closed="both"),
+            _InstancesOf(np.random.RandomState),
+            _NoneConstraint(),
+        ]
+
+    def is_satisfied_by(self, val):
+        return any(c.is_satisfied_by(val) for c in self._constraints)
+
+    def __str__(self):
+        return (
+            f"{', '.join([repr(c) for c in self._constraints[:-1]])} or"
+            f" {self._constraints[-1]}"
+        )
+
+
+def generate_invalid_param_val(constraint):
+    """Return a value that does not satisfy the constraint.
+
+    Parameters
+    ----------
+    constraint : Constraint
+        The constraint to generate a value for.
+
+    Returns
+    -------
+    val : object
+        A value that does not satisfy the constraint.
+    """
+    if isinstance(constraint, StrOptions):
+        return f"not {' or'.join(constraint.options)}"
path: sklearn/utils/_param_validation.py
text: @user1:
```suggestion
        return f"not {' or '.join(constraint.options)}"
```