CoolFace
Apppublic

Aluode/PerceptionLabPortable

sourceHugging Faceupdated 9mo agoView on Hugging Face
0likes
test_base.py1082 linesDownload Raw Back to tests
1# Authors: The scikit-learn developers
2# SPDX-License-Identifier: BSD-3-Clause
3
4import pickle
5import re
6import warnings
7
8import numpy as np
9import pytest
10import scipy.sparse as sp
11from numpy.testing import assert_allclose
12
13import sklearn
14from sklearn import config_context, datasets
15from sklearn.base import (
16    BaseEstimator,
17    OutlierMixin,
18    TransformerMixin,
19    clone,
20    is_classifier,
21    is_clusterer,
22    is_outlier_detector,
23    is_regressor,
24)
25from sklearn.cluster import KMeans
26from sklearn.decomposition import PCA
27from sklearn.ensemble import IsolationForest
28from sklearn.exceptions import InconsistentVersionWarning
29from sklearn.metrics import get_scorer
30from sklearn.model_selection import GridSearchCV, KFold
31from sklearn.pipeline import Pipeline
32from sklearn.preprocessing import StandardScaler
33from sklearn.svm import SVC, SVR
34from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor
35from sklearn.utils._mocking import MockDataFrame
36from sklearn.utils._set_output import _get_output_config
37from sklearn.utils._testing import (
38    _convert_container,
39    assert_array_equal,
40)
41from sklearn.utils.validation import _check_n_features, validate_data
42
43
44#############################################################################
45# A few test classes
46class MyEstimator(BaseEstimator):
47    def __init__(self, l1=0, empty=None):
48        self.l1 = l1
49        self.empty = empty
50
51
52class K(BaseEstimator):
53    def __init__(self, c=None, d=None):
54        self.c = c
55        self.d = d
56
57
58class T(BaseEstimator):
59    def __init__(self, a=None, b=None):
60        self.a = a
61        self.b = b
62
63
64class NaNTag(BaseEstimator):
65    def __sklearn_tags__(self):
66        tags = super().__sklearn_tags__()
67        tags.input_tags.allow_nan = True
68        return tags
69
70
71class NoNaNTag(BaseEstimator):
72    def __sklearn_tags__(self):
73        tags = super().__sklearn_tags__()
74        tags.input_tags.allow_nan = False
75        return tags
76
77
78class OverrideTag(NaNTag):
79    def __sklearn_tags__(self):
80        tags = super().__sklearn_tags__()
81        tags.input_tags.allow_nan = False
82        return tags
83
84
85class DiamondOverwriteTag(NaNTag, NoNaNTag):
86    pass
87
88
89class InheritDiamondOverwriteTag(DiamondOverwriteTag):
90    pass
91
92
93class ModifyInitParams(BaseEstimator):
94    """Deprecated behavior.
95    Equal parameters but with a type cast.
96    Doesn't fulfill a is a
97    """
98
99    def __init__(self, a=np.array([0])):
100        self.a = a.copy()
101
102
103class Buggy(BaseEstimator):
104    "A buggy estimator that does not set its parameters right."
105
106    def __init__(self, a=None):
107        self.a = 1
108
109
110class NoEstimator:
111    def __init__(self):
112        pass
113
114    def fit(self, X=None, y=None):
115        return self
116
117    def predict(self, X=None):
118        return None
119
120
121class VargEstimator(BaseEstimator):
122    """scikit-learn estimators shouldn't have vargs."""
123
124    def __init__(self, *vargs):
125        pass
126
127
128#############################################################################
129# The tests
130
131
132def test_clone():
133    # Tests that clone creates a correct deep copy.
134    # We create an estimator, make a copy of its original state
135    # (which, in this case, is the current state of the estimator),
136    # and check that the obtained copy is a correct deep copy.
137
138    from sklearn.feature_selection import SelectFpr, f_classif
139
140    selector = SelectFpr(f_classif, alpha=0.1)
141    new_selector = clone(selector)
142    assert selector is not new_selector
143    assert selector.get_params() == new_selector.get_params()
144
145    selector = SelectFpr(f_classif, alpha=np.zeros((10, 2)))
146    new_selector = clone(selector)
147    assert selector is not new_selector
148
149
150def test_clone_2():
151    # Tests that clone doesn't copy everything.
152    # We first create an estimator, give it an own attribute, and
153    # make a copy of its original state. Then we check that the copy doesn't
154    # have the specific attribute we manually added to the initial estimator.
155
156    from sklearn.feature_selection import SelectFpr, f_classif
157
158    selector = SelectFpr(f_classif, alpha=0.1)
159    selector.own_attribute = "test"
160    new_selector = clone(selector)
161    assert not hasattr(new_selector, "own_attribute")
162
163
164def test_clone_buggy():
165    # Check that clone raises an error on buggy estimators.
166    buggy = Buggy()
167    buggy.a = 2
168    with pytest.raises(RuntimeError):
169        clone(buggy)
170
171    no_estimator = NoEstimator()
172    with pytest.raises(TypeError):
173        clone(no_estimator)
174
175    varg_est = VargEstimator()
176    with pytest.raises(RuntimeError):
177        clone(varg_est)
178
179    est = ModifyInitParams()
180    with pytest.raises(RuntimeError):
181        clone(est)
182
183
184def test_clone_empty_array():
185    # Regression test for cloning estimators with empty arrays
186    clf = MyEstimator(empty=np.array([]))
187    clf2 = clone(clf)
188    assert_array_equal(clf.empty, clf2.empty)
189
190    clf = MyEstimator(empty=sp.csr_matrix(np.array([[0]])))
191    clf2 = clone(clf)
192    assert_array_equal(clf.empty.data, clf2.empty.data)
193
194
195def test_clone_nan():
196    # Regression test for cloning estimators with default parameter as np.nan
197    clf = MyEstimator(empty=np.nan)
198    clf2 = clone(clf)
199
200    assert clf.empty is clf2.empty
201
202
203def test_clone_dict():
204    # test that clone creates a clone of a dict
205    orig = {"a": MyEstimator()}
206    cloned = clone(orig)
207    assert orig["a"] is not cloned["a"]
208
209
210def test_clone_sparse_matrices():
211    sparse_matrix_classes = [
212        cls
213        for name in dir(sp)
214        if name.endswith("_matrix") and type(cls := getattr(sp, name)) is type
215    ]
216
217    for cls in sparse_matrix_classes:
218        sparse_matrix = cls(np.eye(5))
219        clf = MyEstimator(empty=sparse_matrix)
220        clf_cloned = clone(clf)
221        assert clf.empty.__class__ is clf_cloned.empty.__class__
222        assert_array_equal(clf.empty.toarray(), clf_cloned.empty.toarray())
223
224
225def test_clone_estimator_types():
226    # Check that clone works for parameters that are types rather than
227    # instances
228    clf = MyEstimator(empty=MyEstimator)
229    clf2 = clone(clf)
230
231    assert clf.empty is clf2.empty
232
233
234def test_clone_class_rather_than_instance():
235    # Check that clone raises expected error message when
236    # cloning class rather than instance
237    msg = "You should provide an instance of scikit-learn estimator"
238    with pytest.raises(TypeError, match=msg):
239        clone(MyEstimator)
240
241
242def test_repr():
243    # Smoke test the repr of the base estimator.
244    my_estimator = MyEstimator()
245    repr(my_estimator)
246    test = T(K(), K())
247    assert repr(test) == "T(a=K(), b=K())"
248
249    some_est = T(a=["long_params"] * 1000)
250    assert len(repr(some_est)) == 485
251
252
253def test_str():
254    # Smoke test the str of the base estimator
255    my_estimator = MyEstimator()
256    str(my_estimator)
257
258
259def test_get_params():
260    test = T(K(), K)
261
262    assert "a__d" in test.get_params(deep=True)
263    assert "a__d" not in test.get_params(deep=False)
264
265    test.set_params(a__d=2)
266    assert test.a.d == 2
267
268    with pytest.raises(ValueError):
269        test.set_params(a__a=2)
270
271
272# TODO(1.8): Remove this test when the deprecation is removed
273def test_is_estimator_type_class():
274    with pytest.warns(FutureWarning, match="passing a class to.*is deprecated"):
275        assert is_classifier(SVC)
276
277    with pytest.warns(FutureWarning, match="passing a class to.*is deprecated"):
278        assert is_regressor(SVR)
279
280    with pytest.warns(FutureWarning, match="passing a class to.*is deprecated"):
281        assert is_clusterer(KMeans)
282
283    with pytest.warns(FutureWarning, match="passing a class to.*is deprecated"):
284        assert is_outlier_detector(IsolationForest)
285
286
287@pytest.mark.parametrize(
288    "estimator, expected_result",
289    [
290        (SVC(), True),
291        (GridSearchCV(SVC(), {"C": [0.1, 1]}), True),
292        (Pipeline([("svc", SVC())]), True),
293        (Pipeline([("svc_cv", GridSearchCV(SVC(), {"C": [0.1, 1]}))]), True),
294        (SVR(), False),
295        (GridSearchCV(SVR(), {"C": [0.1, 1]}), False),
296        (Pipeline([("svr", SVR())]), False),
297        (Pipeline([("svr_cv", GridSearchCV(SVR(), {"C": [0.1, 1]}))]), False),
298    ],
299)
300def test_is_classifier(estimator, expected_result):
301    assert is_classifier(estimator) == expected_result
302
303
304@pytest.mark.parametrize(
305    "estimator, expected_result",
306    [
307        (SVR(), True),
308        (GridSearchCV(SVR(), {"C": [0.1, 1]}), True),
309        (Pipeline([("svr", SVR())]), True),
310        (Pipeline([("svr_cv", GridSearchCV(SVR(), {"C": [0.1, 1]}))]), True),
311        (SVC(), False),
312        (GridSearchCV(SVC(), {"C": [0.1, 1]}), False),
313        (Pipeline([("svc", SVC())]), False),
314        (Pipeline([("svc_cv", GridSearchCV(SVC(), {"C": [0.1, 1]}))]), False),
315    ],
316)
317def test_is_regressor(estimator, expected_result):
318    assert is_regressor(estimator) == expected_result
319
320
321@pytest.mark.parametrize(
322    "estimator, expected_result",
323    [
324        (KMeans(), True),
325        (GridSearchCV(KMeans(), {"n_clusters": [3, 8]}), True),
326        (Pipeline([("km", KMeans())]), True),
327        (Pipeline([("km_cv", GridSearchCV(KMeans(), {"n_clusters": [3, 8]}))]), True),
328        (SVC(), False),
329        (GridSearchCV(SVC(), {"C": [0.1, 1]}), False),
330        (Pipeline([("svc", SVC())]), False),
331        (Pipeline([("svc_cv", GridSearchCV(SVC(), {"C": [0.1, 1]}))]), False),
332    ],
333)
334def test_is_clusterer(estimator, expected_result):
335    assert is_clusterer(estimator) == expected_result
336
337
338def test_set_params():
339    # test nested estimator parameter setting
340    clf = Pipeline([("svc", SVC())])
341
342    # non-existing parameter in svc
343    with pytest.raises(ValueError):
344        clf.set_params(svc__stupid_param=True)
345
346    # non-existing parameter of pipeline
347    with pytest.raises(ValueError):
348        clf.set_params(svm__stupid_param=True)
349
350    # we don't currently catch if the things in pipeline are estimators
351    # bad_pipeline = Pipeline([("bad", NoEstimator())])
352    # with pytest.raises(AttributeError):
353    #    bad_pipeline.set_params(bad__stupid_param=True)
354
355
356def test_set_params_passes_all_parameters():
357    # Make sure all parameters are passed together to set_params
358    # of nested estimator. Regression test for #9944
359
360    class TestDecisionTree(DecisionTreeClassifier):
361        def set_params(self, **kwargs):
362            super().set_params(**kwargs)
363            # expected_kwargs is in test scope
364            assert kwargs == expected_kwargs
365            return self
366
367    expected_kwargs = {"max_depth": 5, "min_samples_leaf": 2}
368    for est in [
369        Pipeline([("estimator", TestDecisionTree())]),
370        GridSearchCV(TestDecisionTree(), {}),
371    ]:
372        est.set_params(estimator__max_depth=5, estimator__min_samples_leaf=2)
373
374
375def test_set_params_updates_valid_params():
376    # Check that set_params tries to set SVC().C, not
377    # DecisionTreeClassifier().C
378    gscv = GridSearchCV(DecisionTreeClassifier(), {})
379    gscv.set_params(estimator=SVC(), estimator__C=42.0)
380    assert gscv.estimator.C == 42.0
381
382
383@pytest.mark.parametrize(
384    "tree,dataset",
385    [
386        (
387            DecisionTreeClassifier(max_depth=2, random_state=0),
388            datasets.make_classification(random_state=0),
389        ),
390        (
391            DecisionTreeRegressor(max_depth=2, random_state=0),
392            datasets.make_regression(random_state=0),
393        ),
394    ],
395)
396def test_score_sample_weight(tree, dataset):
397    rng = np.random.RandomState(0)
398    # check that the score with and without sample weights are different
399    X, y = dataset
400
401    tree.fit(X, y)
402    # generate random sample weights
403    sample_weight = rng.randint(1, 10, size=len(y))
404    score_unweighted = tree.score(X, y)
405    score_weighted = tree.score(X, y, sample_weight=sample_weight)
406    msg = "Unweighted and weighted scores are unexpectedly equal"
407    assert score_unweighted != score_weighted, msg
408
409
410def test_clone_pandas_dataframe():
411    class DummyEstimator(TransformerMixin, BaseEstimator):
412        """This is a dummy class for generating numerical features
413
414        This feature extractor extracts numerical features from pandas data
415        frame.
416
417        Parameters
418        ----------
419
420        df: pandas data frame
421            The pandas data frame parameter.
422
423        Notes
424        -----
425        """
426
427        def __init__(self, df=None, scalar_param=1):
428            self.df = df
429            self.scalar_param = scalar_param
430
431        def fit(self, X, y=None):
432            pass
433
434        def transform(self, X):
435            pass
436
437    # build and clone estimator
438    d = np.arange(10)
439    df = MockDataFrame(d)
440    e = DummyEstimator(df, scalar_param=1)
441    cloned_e = clone(e)
442
443    # the test
444    assert (e.df == cloned_e.df).values.all()
445    assert e.scalar_param == cloned_e.scalar_param
446
447
448def test_clone_protocol():
449    """Checks that clone works with `__sklearn_clone__` protocol."""
450
451    class FrozenEstimator(BaseEstimator):
452        def __init__(self, fitted_estimator):
453            self.fitted_estimator = fitted_estimator
454
455        def __getattr__(self, name):
456            return getattr(self.fitted_estimator, name)
457
458        def __sklearn_clone__(self):
459            return self
460
461        def fit(self, *args, **kwargs):
462            return self
463
464        def fit_transform(self, *args, **kwargs):
465            return self.fitted_estimator.transform(*args, **kwargs)
466
467    X = np.array([[-1, -1], [-2, -1], [-3, -2]])
468    pca = PCA().fit(X)
469    components = pca.components_
470
471    frozen_pca = FrozenEstimator(pca)
472    assert_allclose(frozen_pca.components_, components)
473
474    # Calling PCA methods such as `get_feature_names_out` still works
475    assert_array_equal(frozen_pca.get_feature_names_out(), pca.get_feature_names_out())
476
477    # Fitting on a new data does not alter `components_`
478    X_new = np.asarray([[-1, 2], [3, 4], [1, 2]])
479    frozen_pca.fit(X_new)
480    assert_allclose(frozen_pca.components_, components)
481
482    # `fit_transform` does not alter state
483    frozen_pca.fit_transform(X_new)
484    assert_allclose(frozen_pca.components_, components)
485
486    # Cloning estimator is a no-op
487    clone_frozen_pca = clone(frozen_pca)
488    assert clone_frozen_pca is frozen_pca
489    assert_allclose(clone_frozen_pca.components_, components)
490
491
492def test_pickle_version_warning_is_not_raised_with_matching_version():
493    iris = datasets.load_iris()
494    tree = DecisionTreeClassifier().fit(iris.data, iris.target)
495    tree_pickle = pickle.dumps(tree)
496    assert b"_sklearn_version" in tree_pickle
497
498    with warnings.catch_warnings():
499        warnings.simplefilter("error")
500        tree_restored = pickle.loads(tree_pickle)
501
502    # test that we can predict with the restored decision tree classifier
503    score_of_original = tree.score(iris.data, iris.target)
504    score_of_restored = tree_restored.score(iris.data, iris.target)
505    assert score_of_original == score_of_restored
506
507
508class TreeBadVersion(DecisionTreeClassifier):
509    def __getstate__(self):
510        return dict(self.__dict__.items(), _sklearn_version="something")
511
512
513pickle_error_message = (
514    "Trying to unpickle estimator {estimator} from "
515    "version {old_version} when using version "
516    "{current_version}. This might "
517    "lead to breaking code or invalid results. "
518    "Use at your own risk."
519)
520
521
522def test_pickle_version_warning_is_issued_upon_different_version():
523    iris = datasets.load_iris()
524    tree = TreeBadVersion().fit(iris.data, iris.target)
525    tree_pickle_other = pickle.dumps(tree)
526    message = pickle_error_message.format(
527        estimator="TreeBadVersion",
528        old_version="something",
529        current_version=sklearn.__version__,
530    )
531    with pytest.warns(UserWarning, match=message) as warning_record:
532        pickle.loads(tree_pickle_other)
533
534    message = warning_record.list[0].message
535    assert isinstance(message, InconsistentVersionWarning)
536    assert message.estimator_name == "TreeBadVersion"
537    assert message.original_sklearn_version == "something"
538    assert message.current_sklearn_version == sklearn.__version__
539
540
541class TreeNoVersion(DecisionTreeClassifier):
542    def __getstate__(self):
543        return self.__dict__
544
545
546def test_pickle_version_warning_is_issued_when_no_version_info_in_pickle():
547    iris = datasets.load_iris()
548    # TreeNoVersion has no getstate, like pre-0.18
549    tree = TreeNoVersion().fit(iris.data, iris.target)
550
551    tree_pickle_noversion = pickle.dumps(tree)
552    assert b"_sklearn_version" not in tree_pickle_noversion
553    message = pickle_error_message.format(
554        estimator="TreeNoVersion",
555        old_version="pre-0.18",
556        current_version=sklearn.__version__,
557    )
558    # check we got the warning about using pre-0.18 pickle
559    with pytest.warns(UserWarning, match=message):
560        pickle.loads(tree_pickle_noversion)
561
562
563def test_pickle_version_no_warning_is_issued_with_non_sklearn_estimator():
564    iris = datasets.load_iris()
565    tree = TreeNoVersion().fit(iris.data, iris.target)
566    tree_pickle_noversion = pickle.dumps(tree)
567    try:
568        module_backup = TreeNoVersion.__module__
569        TreeNoVersion.__module__ = "notsklearn"
570
571        with warnings.catch_warnings():
572            warnings.simplefilter("error")
573
574            pickle.loads(tree_pickle_noversion)
575    finally:
576        TreeNoVersion.__module__ = module_backup
577
578
579class DontPickleAttributeMixin:
580    def __getstate__(self):
581        data = self.__dict__.copy()
582        data["_attribute_not_pickled"] = None
583        return data
584
585    def __setstate__(self, state):
586        state["_restored"] = True
587        self.__dict__.update(state)
588
589
590class MultiInheritanceEstimator(DontPickleAttributeMixin, BaseEstimator):
591    def __init__(self, attribute_pickled=5):
592        self.attribute_pickled = attribute_pickled
593        self._attribute_not_pickled = None
594
595
596def test_pickling_when_getstate_is_overwritten_by_mixin():
597    estimator = MultiInheritanceEstimator()
598    estimator._attribute_not_pickled = "this attribute should not be pickled"
599
600    serialized = pickle.dumps(estimator)
601    estimator_restored = pickle.loads(serialized)
602    assert estimator_restored.attribute_pickled == 5
603    assert estimator_restored._attribute_not_pickled is None
604    assert estimator_restored._restored
605
606
607def test_pickling_when_getstate_is_overwritten_by_mixin_outside_of_sklearn():
608    try:
609        estimator = MultiInheritanceEstimator()
610        text = "this attribute should not be pickled"
611        estimator._attribute_not_pickled = text
612        old_mod = type(estimator).__module__
613        type(estimator).__module__ = "notsklearn"
614
615        serialized = estimator.__getstate__()
616        assert serialized == {"_attribute_not_pickled": None, "attribute_pickled": 5}
617
618        serialized["attribute_pickled"] = 4
619        estimator.__setstate__(serialized)
620        assert estimator.attribute_pickled == 4
621        assert estimator._restored
622    finally:
623        type(estimator).__module__ = old_mod
624
625
626class SingleInheritanceEstimator(BaseEstimator):
627    def __init__(self, attribute_pickled=5):
628        self.attribute_pickled = attribute_pickled
629        self._attribute_not_pickled = None
630
631    def __getstate__(self):
632        state = super().__getstate__()
633        state["_attribute_not_pickled"] = None
634        return state
635
636
637def test_pickling_works_when_getstate_is_overwritten_in_the_child_class():
638    estimator = SingleInheritanceEstimator()
639    estimator._attribute_not_pickled = "this attribute should not be pickled"
640
641    serialized = pickle.dumps(estimator)
642    estimator_restored = pickle.loads(serialized)
643    assert estimator_restored.attribute_pickled == 5
644    assert estimator_restored._attribute_not_pickled is None
645
646
647def test_tag_inheritance():
648    # test that changing tags by inheritance is not allowed
649
650    nan_tag_est = NaNTag()
651    no_nan_tag_est = NoNaNTag()
652    assert nan_tag_est.__sklearn_tags__().input_tags.allow_nan
653    assert not no_nan_tag_est.__sklearn_tags__().input_tags.allow_nan
654
655    redefine_tags_est = OverrideTag()
656    assert not redefine_tags_est.__sklearn_tags__().input_tags.allow_nan
657
658    diamond_tag_est = DiamondOverwriteTag()
659    assert diamond_tag_est.__sklearn_tags__().input_tags.allow_nan
660
661    inherit_diamond_tag_est = InheritDiamondOverwriteTag()
662    assert inherit_diamond_tag_est.__sklearn_tags__().input_tags.allow_nan
663
664
665def test_raises_on_get_params_non_attribute():
666    class MyEstimator(BaseEstimator):
667        def __init__(self, param=5):
668            pass
669
670        def fit(self, X, y=None):
671            return self
672
673    est = MyEstimator()
674    msg = "'MyEstimator' object has no attribute 'param'"
675
676    with pytest.raises(AttributeError, match=msg):
677        est.get_params()
678
679
680def test_repr_mimebundle_():
681    # Checks the display configuration flag controls the json output
682    tree = DecisionTreeClassifier()
683    output = tree._repr_mimebundle_()
684    assert "text/plain" in output
685    assert "text/html" in output
686
687    with config_context(display="text"):
688        output = tree._repr_mimebundle_()
689        assert "text/plain" in output
690        assert "text/html" not in output
691
692
693def test_repr_html_wraps():
694    # Checks the display configuration flag controls the html output
695    tree = DecisionTreeClassifier()
696
697    output = tree._repr_html_()
698    assert "<style>" in output
699
700    with config_context(display="text"):
701        msg = "_repr_html_ is only defined when"
702        with pytest.raises(AttributeError, match=msg):
703            output = tree._repr_html_()
704
705
706def test_n_features_in_validation():
707    """Check that `_check_n_features` validates data when reset=False"""
708    est = MyEstimator()
709    X_train = [[1, 2, 3], [4, 5, 6]]
710    _check_n_features(est, X_train, reset=True)
711
712    assert est.n_features_in_ == 3
713
714    msg = "X does not contain any features, but MyEstimator is expecting 3 features"
715    with pytest.raises(ValueError, match=msg):
716        _check_n_features(est, "invalid X", reset=False)
717
718
719def test_n_features_in_no_validation():
720    """Check that `_check_n_features` does not validate data when
721    n_features_in_ is not defined."""
722    est = MyEstimator()
723    _check_n_features(est, "invalid X", reset=True)
724
725    assert not hasattr(est, "n_features_in_")
726
727    # does not raise
728    _check_n_features(est, "invalid X", reset=False)
729
730
731def test_feature_names_in():
732    """Check that feature_name_in are recorded by `_validate_data`"""
733    pd = pytest.importorskip("pandas")
734    iris = datasets.load_iris()
735    X_np = iris.data
736    df = pd.DataFrame(X_np, columns=iris.feature_names)
737
738    class NoOpTransformer(TransformerMixin, BaseEstimator):
739        def fit(self, X, y=None):
740            validate_data(self, X)
741            return self
742
743        def transform(self, X):
744            validate_data(self, X, reset=False)
745            return X
746
747    # fit on dataframe saves the feature names
748    trans = NoOpTransformer().fit(df)
749    assert_array_equal(trans.feature_names_in_, df.columns)
750
751    # fit again but on ndarray does not keep the previous feature names (see #21383)
752    trans.fit(X_np)
753    assert not hasattr(trans, "feature_names_in_")
754
755    trans.fit(df)
756    msg = "The feature names should match those that were passed"
757    df_bad = pd.DataFrame(X_np, columns=iris.feature_names[::-1])
758    with pytest.raises(ValueError, match=msg):
759        trans.transform(df_bad)
760
761    # warns when fitted on dataframe and transforming a ndarray
762    msg = (
763        "X does not have valid feature names, but NoOpTransformer was "
764        "fitted with feature names"
765    )
766    with pytest.warns(UserWarning, match=msg):
767        trans.transform(X_np)
768
769    # warns when fitted on a ndarray and transforming dataframe
770    msg = "X has feature names, but NoOpTransformer was fitted without feature names"
771    trans = NoOpTransformer().fit(X_np)
772    with pytest.warns(UserWarning, match=msg):
773        trans.transform(df)
774
775    # fit on dataframe with all integer feature names works without warning
776    df_int_names = pd.DataFrame(X_np)
777    trans = NoOpTransformer()
778    with warnings.catch_warnings():
779        warnings.simplefilter("error", UserWarning)
780        trans.fit(df_int_names)
781
782    # fit on dataframe with no feature names or all integer feature names
783    # -> do not warn on transform
784    Xs = [X_np, df_int_names]
785    for X in Xs:
786        with warnings.catch_warnings():
787            warnings.simplefilter("error", UserWarning)
788            trans.transform(X)
789
790    # fit on dataframe with feature names that are mixed raises an error:
791    df_mixed = pd.DataFrame(X_np, columns=["a", "b", 1, 2])
792    trans = NoOpTransformer()
793    msg = re.escape(
794        "Feature names are only supported if all input features have string names, "
795        "but your input has ['int', 'str'] as feature name / column name types. "
796        "If you want feature names to be stored and validated, you must convert "
797        "them all to strings, by using X.columns = X.columns.astype(str) for "
798        "example. Otherwise you can remove feature / column names from your input "
799        "data, or convert them all to a non-string data type."
800    )
801    with pytest.raises(TypeError, match=msg):
802        trans.fit(df_mixed)
803
804    # transform on feature names that are mixed also raises:
805    with pytest.raises(TypeError, match=msg):
806        trans.transform(df_mixed)
807
808
809def test_validate_data_skip_check_array():
810    """Check skip_check_array option of _validate_data."""
811
812    pd = pytest.importorskip("pandas")
813    iris = datasets.load_iris()
814    df = pd.DataFrame(iris.data, columns=iris.feature_names)
815    y = pd.Series(iris.target)
816
817    class NoOpTransformer(TransformerMixin, BaseEstimator):
818        pass
819
820    no_op = NoOpTransformer()
821    X_np_out = validate_data(no_op, df, skip_check_array=False)
822    assert isinstance(X_np_out, np.ndarray)
823    assert_allclose(X_np_out, df.to_numpy())
824
825    X_df_out = validate_data(no_op, df, skip_check_array=True)
826    assert X_df_out is df
827
828    y_np_out = validate_data(no_op, y=y, skip_check_array=False)
829    assert isinstance(y_np_out, np.ndarray)
830    assert_allclose(y_np_out, y.to_numpy())
831
832    y_series_out = validate_data(no_op, y=y, skip_check_array=True)
833    assert y_series_out is y
834
835    X_np_out, y_np_out = validate_data(no_op, df, y, skip_check_array=False)
836    assert isinstance(X_np_out, np.ndarray)
837    assert_allclose(X_np_out, df.to_numpy())
838    assert isinstance(y_np_out, np.ndarray)
839    assert_allclose(y_np_out, y.to_numpy())
840
841    X_df_out, y_series_out = validate_data(no_op, df, y, skip_check_array=True)
842    assert X_df_out is df
843    assert y_series_out is y
844
845    msg = "Validation should be done on X, y or both."
846    with pytest.raises(ValueError, match=msg):
847        validate_data(no_op)
848
849
850def test_clone_keeps_output_config():
851    """Check that clone keeps the set_output config."""
852
853    ss = StandardScaler().set_output(transform="pandas")
854    config = _get_output_config("transform", ss)
855
856    ss_clone = clone(ss)
857    config_clone = _get_output_config("transform", ss_clone)
858    assert config == config_clone
859
860
861class _Empty:
862    pass
863
864
865class EmptyEstimator(_Empty, BaseEstimator):
866    pass
867
868
869@pytest.mark.parametrize("estimator", [BaseEstimator(), EmptyEstimator()])
870def test_estimator_empty_instance_dict(estimator):
871    """Check that ``__getstate__`` returns an empty ``dict`` with an empty
872    instance.
873
874    Python 3.11+ changed behaviour by returning ``None`` instead of raising an
875    ``AttributeError``. Non-regression test for gh-25188.
876    """
877    state = estimator.__getstate__()
878    expected = {"_sklearn_version": sklearn.__version__}
879    assert state == expected
880
881    # this should not raise
882    pickle.loads(pickle.dumps(BaseEstimator()))
883
884
885def test_estimator_getstate_using_slots_error_message():
886    """Using a `BaseEstimator` with `__slots__` is not supported."""
887
888    class WithSlots:
889        __slots__ = ("x",)
890
891    class Estimator(BaseEstimator, WithSlots):
892        pass
893
894    msg = (
895        "You cannot use `__slots__` in objects inheriting from "
896        "`sklearn.base.BaseEstimator`"
897    )
898
899    with pytest.raises(TypeError, match=msg):
900        Estimator().__getstate__()
901
902    with pytest.raises(TypeError, match=msg):
903        pickle.dumps(Estimator())
904
905
906@pytest.mark.parametrize(
907    "constructor_name, minversion",
908    [
909        ("dataframe", "1.5.0"),
910        ("pyarrow", "12.0.0"),
911        ("polars", "0.20.23"),
912    ],
913)
914def test_dataframe_protocol(constructor_name, minversion):
915    """Uses the dataframe exchange protocol to get feature names."""
916    data = [[1, 4, 2], [3, 3, 6]]
917    columns = ["col_0", "col_1", "col_2"]
918    df = _convert_container(
919        data, constructor_name, columns_name=columns, minversion=minversion
920    )
921
922    class NoOpTransformer(TransformerMixin, BaseEstimator):
923        def fit(self, X, y=None):
924            validate_data(self, X)
925            return self
926
927        def transform(self, X):
928            return validate_data(self, X, reset=False)
929
930    no_op = NoOpTransformer()
931    no_op.fit(df)
932    assert_array_equal(no_op.feature_names_in_, columns)
933    X_out = no_op.transform(df)
934
935    if constructor_name != "pyarrow":
936        # pyarrow does not work with `np.asarray`
937        # https://github.com/apache/arrow/issues/34886
938        assert_allclose(df, X_out)
939
940    bad_names = ["a", "b", "c"]
941    df_bad = _convert_container(data, constructor_name, columns_name=bad_names)
942    with pytest.raises(ValueError, match="The feature names should match"):
943        no_op.transform(df_bad)
944
945
946@config_context(enable_metadata_routing=True)
947def test_transformer_fit_transform_with_metadata_in_transform():
948    """Test that having a transformer with metadata for transform raises a
949    warning when calling fit_transform."""
950
951    class CustomTransformer(BaseEstimator, TransformerMixin):
952        def fit(self, X, y=None, prop=None):
953            return self
954
955        def transform(self, X, prop=None):
956            return X
957
958    # passing the metadata to `fit_transform` should raise a warning since it
959    # could potentially be consumed by `transform`
960    with pytest.warns(UserWarning, match="`transform` method which consumes metadata"):
961        CustomTransformer().set_transform_request(prop=True).fit_transform(
962            [[1]], [1], prop=1
963        )
964
965    # not passing a metadata which can potentially be consumed by `transform` should
966    # not raise a warning
967    with warnings.catch_warnings(record=True) as record:
968        CustomTransformer().set_transform_request(prop=True).fit_transform([[1]], [1])
969        assert len(record) == 0
970
971
972@config_context(enable_metadata_routing=True)
973def test_outlier_mixin_fit_predict_with_metadata_in_predict():
974    """Test that having an OutlierMixin with metadata for predict raises a
975    warning when calling fit_predict."""
976
977    class CustomOutlierDetector(BaseEstimator, OutlierMixin):
978        def fit(self, X, y=None, prop=None):
979            return self
980
981        def predict(self, X, prop=None):
982            return X
983
984    # passing the metadata to `fit_predict` should raise a warning since it
985    # could potentially be consumed by `predict`
986    with pytest.warns(UserWarning, match="`predict` method which consumes metadata"):
987        CustomOutlierDetector().set_predict_request(prop=True).fit_predict(
988            [[1]], [1], prop=1
989        )
990
991    # not passing a metadata which can potentially be consumed by `predict` should
992    # not raise a warning
993    with warnings.catch_warnings(record=True) as record:
994        CustomOutlierDetector().set_predict_request(prop=True).fit_predict([[1]], [1])
995        assert len(record) == 0
996
997
998def test_get_params_html():
999    """Check the behaviour of the `_get_params_html` method."""
1000    est = MyEstimator(empty="test")
1001
1002    assert est._get_params_html() == {"l1": 0, "empty": "test"}
1003    assert est._get_params_html().non_default == ("empty",)
1004
1005
1006def make_estimator_with_param(default_value):
1007    class DynamicEstimator(BaseEstimator):
1008        def __init__(self, param=default_value):
1009            self.param = param
1010
1011    return DynamicEstimator
1012
1013
1014@pytest.mark.parametrize(
1015    "default_value, test_value",
1016    [
1017        ((), (1,)),
1018        ((), [1]),
1019        ((), np.array([1])),
1020        ((1, 2), (3, 4)),
1021        ((1, 2), [3, 4]),
1022        ((1, 2), np.array([3, 4])),
1023        (None, 1),
1024        (None, []),
1025        (None, lambda x: x),
1026        (np.nan, 1.0),
1027        (np.nan, np.array([np.nan])),
1028        ("abc", "def"),
1029        ("abc", ["abc"]),
1030        (True, False),
1031        (1, 2),
1032        (1, [1]),
1033        (1, np.array([1])),
1034        (1.0, 2.0),
1035        (1.0, [1.0]),
1036        (1.0, np.array([1.0])),
1037        ([1, 2], [3]),
1038        (np.array([1]), [2, 3]),
1039        (None, KFold()),
1040        (None, get_scorer("accuracy")),
1041    ],
1042)
1043def test_param_is_non_default(default_value, test_value):
1044    """Check that we detect non-default parameters with various types.
1045
1046    Non-regression test for:
1047    https://github.com/scikit-learn/scikit-learn/issues/31525
1048    """
1049    estimator = make_estimator_with_param(default_value)(param=test_value)
1050    non_default = estimator._get_params_html().non_default
1051    assert "param" in non_default
1052
1053
1054@pytest.mark.parametrize(
1055    "default_value, test_value",
1056    [
1057        (None, None),
1058        ((), ()),
1059        ((), []),
1060        ((), np.array([])),
1061        ((1, 2, 3), (1, 2, 3)),
1062        ((1, 2, 3), [1, 2, 3]),
1063        ((1, 2, 3), np.array([1, 2, 3])),
1064        (np.nan, np.nan),
1065        ("abc", "abc"),
1066        (True, True),
1067        (1, 1),
1068        (1.0, 1.0),
1069        (2, 2.0),
1070    ],
1071)
1072def test_param_is_default(default_value, test_value):
1073    """Check that we detect the default parameters and values in an array-like will
1074    be reported as default as well.
1075
1076    Non-regression test for:
1077    https://github.com/scikit-learn/scikit-learn/issues/31525
1078    """
1079    estimator = make_estimator_with_param(default_value)(param=test_value)
1080    non_default = estimator._get_params_html().non_default
1081    assert "param" not in non_default
1082