Aluode/PerceptionLabPortable
0
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 