From 548a4524b43f2e63d8eeb1ea75c052d4d4bf6dd8 Mon Sep 17 00:00:00 2001 From: Olivier Grisel Date: Thu, 8 Oct 2020 15:47:24 +0200 Subject: [PATCH] MNT n_features_in_ consistency in decomposition (#18557) Co-authored-by: Nicolas Hug --- sklearn/decomposition/_base.py | 3 +- sklearn/decomposition/_dict_learning.py | 4 +-- sklearn/decomposition/_factor_analysis.py | 6 ++-- sklearn/decomposition/_fastica.py | 5 ++-- sklearn/decomposition/_incremental_pca.py | 9 ++++-- sklearn/decomposition/_kernel_pca.py | 1 + sklearn/decomposition/_lda.py | 21 ++++++-------- sklearn/decomposition/_nmf.py | 7 +++++ sklearn/decomposition/_pca.py | 3 +- sklearn/decomposition/_sparse_pca.py | 4 +-- sklearn/decomposition/_truncated_svd.py | 2 +- .../decomposition/tests/test_online_lda.py | 29 ------------------- sklearn/tests/test_common.py | 2 +- 13 files changed, 37 insertions(+), 59 deletions(-) diff --git a/sklearn/decomposition/_base.py b/sklearn/decomposition/_base.py index e89a0505140..4f17d3df7ba 100644 --- a/sklearn/decomposition/_base.py +++ b/sklearn/decomposition/_base.py @@ -12,7 +12,6 @@ import numpy as np from scipy import linalg from ..base import BaseEstimator, TransformerMixin -from ..utils import check_array from ..utils.validation import check_is_fitted from abc import ABCMeta, abstractmethod @@ -124,7 +123,7 @@ class _BasePCA(TransformerMixin, BaseEstimator, metaclass=ABCMeta): """ check_is_fitted(self) - X = check_array(X) + X = self._validate_data(X, dtype=[np.float64, np.float32], reset=False) if self.mean_ is not None: X = X - self.mean_ X_transformed = np.dot(X, self.components_.T) diff --git a/sklearn/decomposition/_dict_learning.py b/sklearn/decomposition/_dict_learning.py index 71cbfde40d1..74a4d4f4d17 100644 --- a/sklearn/decomposition/_dict_learning.py +++ b/sklearn/decomposition/_dict_learning.py @@ -907,7 +907,7 @@ class _BaseSparseCoding(TransformerMixin): def _transform(self, X, dictionary): """Private method allowing to accomodate both DictionaryLearning and SparseCoder.""" - X = check_array(X) + X = self._validate_data(X, reset=False) code = sparse_encode( X, dictionary, algorithm=self.transform_algorithm, @@ -1622,7 +1622,6 @@ class MiniBatchDictionaryLearning(_BaseSparseCoding, BaseEstimator): """ if not hasattr(self, 'random_state_'): self.random_state_ = check_random_state(self.random_state) - X = check_array(X) if hasattr(self, 'components_'): dict_init = self.components_ else: @@ -1630,6 +1629,7 @@ class MiniBatchDictionaryLearning(_BaseSparseCoding, BaseEstimator): inner_stats = getattr(self, 'inner_stats_', None) if iter_offset is None: iter_offset = getattr(self, 'iter_offset_', 0) + X = self._validate_data(X, reset=(iter_offset == 0)) U, (A, B) = dict_learning_online( X, self.n_components, alpha=self.alpha, n_iter=1, method=self.fit_algorithm, diff --git a/sklearn/decomposition/_factor_analysis.py b/sklearn/decomposition/_factor_analysis.py index d33ce06ea09..6446eecc5ab 100644 --- a/sklearn/decomposition/_factor_analysis.py +++ b/sklearn/decomposition/_factor_analysis.py @@ -26,7 +26,7 @@ from scipy import linalg from ..base import BaseEstimator, TransformerMixin -from ..utils import check_array, check_random_state +from ..utils import check_random_state from ..utils.extmath import fast_logdet, randomized_svd, squared_norm from ..utils.validation import check_is_fitted, _deprecate_positional_args from ..exceptions import ConvergenceWarning @@ -279,7 +279,7 @@ class FactorAnalysis(TransformerMixin, BaseEstimator): """ check_is_fitted(self) - X = check_array(X) + X = self._validate_data(X, reset=False) Ih = np.eye(len(self.components_)) X_transformed = X - self.mean_ @@ -350,7 +350,7 @@ class FactorAnalysis(TransformerMixin, BaseEstimator): Log-likelihood of each sample under the current model """ check_is_fitted(self) - + X = self._validate_data(X, reset=False) Xr = X - self.mean_ precision = self.get_precision() n_features = X.shape[1] diff --git a/sklearn/decomposition/_fastica.py b/sklearn/decomposition/_fastica.py index 566e345ba3f..e4a83542ffd 100644 --- a/sklearn/decomposition/_fastica.py +++ b/sklearn/decomposition/_fastica.py @@ -584,7 +584,7 @@ class FastICA(TransformerMixin, BaseEstimator): and n_features is the number of features. copy : bool, default=True - If False, data passed to fit are overwritten. Defaults to True. + If False, data passed to fit can be overwritten. Defaults to True. Returns ------- @@ -592,7 +592,8 @@ class FastICA(TransformerMixin, BaseEstimator): """ check_is_fitted(self) - X = check_array(X, copy=copy, dtype=FLOAT_DTYPES) + X = self._validate_data(X, copy=(copy and self.whiten), + dtype=FLOAT_DTYPES, reset=False) if self.whiten: X -= self.mean_ diff --git a/sklearn/decomposition/_incremental_pca.py b/sklearn/decomposition/_incremental_pca.py index 5090184e374..90ef3a022ee 100644 --- a/sklearn/decomposition/_incremental_pca.py +++ b/sklearn/decomposition/_incremental_pca.py @@ -8,7 +8,7 @@ import numpy as np from scipy import linalg, sparse from ._base import _BasePCA -from ..utils import check_array, gen_batches +from ..utils import gen_batches from ..utils.extmath import svd_flip, _incremental_mean_and_var from ..utils.validation import _deprecate_positional_args @@ -234,15 +234,18 @@ class IncrementalPCA(_BasePCA): self : object Returns the instance itself. """ + first_pass = not hasattr(self, "components_") if check_input: if sparse.issparse(X): raise TypeError( "IncrementalPCA.partial_fit does not support " "sparse input. Either convert data to dense " "or use IncrementalPCA.fit to do so in batches.") - X = check_array(X, copy=self.copy, dtype=[np.float64, np.float32]) + X = self._validate_data( + X, copy=self.copy, dtype=[np.float64, np.float32], + reset=first_pass) n_samples, n_features = X.shape - if not hasattr(self, 'components_'): + if first_pass: self.components_ = None if self.n_components is None: diff --git a/sklearn/decomposition/_kernel_pca.py b/sklearn/decomposition/_kernel_pca.py index e7233bd17d7..ab5769c4708 100644 --- a/sklearn/decomposition/_kernel_pca.py +++ b/sklearn/decomposition/_kernel_pca.py @@ -331,6 +331,7 @@ class KernelPCA(TransformerMixin, BaseEstimator): X_new : ndarray of shape (n_samples, n_components) """ check_is_fitted(self) + X = self._validate_data(X, accept_sparse='csr', reset=False) # Compute centered gram matrix between X and training data X_fit_ K = self._centerer.transform(self._get_kernel(X, self.X_fit_)) diff --git a/sklearn/decomposition/_lda.py b/sklearn/decomposition/_lda.py index 14dd87b9db1..6e6a5627ff7 100644 --- a/sklearn/decomposition/_lda.py +++ b/sklearn/decomposition/_lda.py @@ -509,17 +509,9 @@ class LatentDirichletAllocation(TransformerMixin, BaseEstimator): """ self._check_params() first_time = not hasattr(self, 'components_') - - # In theory reset should be equal to `first_time`, but there are tests - # checking the input number of feature and they expect a specific - # string, which is not the same one raised by check_n_features. So we - # don't check n_features_in_ here for now (it's done with adhoc code in - # the estimator anyway). - # TODO: set reset=first_time when addressing reset in - # predict/transform/etc. - reset_n_features = True - X = self._check_non_neg_array(X, reset_n_features, - "LatentDirichletAllocation.partial_fit") + X = self._check_non_neg_array( + X, reset_n_features=first_time, + whom="LatentDirichletAllocation.partial_fit") n_samples, n_features = X.shape batch_size = self.batch_size @@ -663,6 +655,10 @@ class LatentDirichletAllocation(TransformerMixin, BaseEstimator): doc_topic_distr : ndarray of shape (n_samples, n_components) Document topic distribution for X. """ + check_is_fitted(self) + X = self._check_non_neg_array( + X, reset_n_features=False, + whom="LatentDirichletAllocation.transform") doc_topic_distr = self._unnormalized_transform(X) doc_topic_distr /= doc_topic_distr.sum(axis=1)[:, np.newaxis] return doc_topic_distr @@ -758,7 +754,8 @@ class LatentDirichletAllocation(TransformerMixin, BaseEstimator): score : float Use approximate bound as score. """ - X = self._check_non_neg_array(X, reset_n_features=True, + check_is_fitted(self) + X = self._check_non_neg_array(X, reset_n_features=False, whom="LatentDirichletAllocation.score") doc_topic_distr = self._unnormalized_transform(X) diff --git a/sklearn/decomposition/_nmf.py b/sklearn/decomposition/_nmf.py index 4eaf9c29e57..9a6d0a665fc 100644 --- a/sklearn/decomposition/_nmf.py +++ b/sklearn/decomposition/_nmf.py @@ -1299,6 +1299,8 @@ class NMF(TransformerMixin, BaseEstimator): X = self._validate_data(X, accept_sparse=('csr', 'csc'), dtype=[np.float64, np.float32]) + # XXX: input data validation is performed again in + # non_negative_factorization. W, H, n_iter_ = non_negative_factorization( X=X, W=W, H=H, n_components=self.n_components, init=self.init, update_H=True, solver=self.solver, beta_loss=self.beta_loss, @@ -1347,7 +1349,12 @@ class NMF(TransformerMixin, BaseEstimator): Transformed data. """ check_is_fitted(self) + X = self._validate_data(X, accept_sparse=('csr', 'csc'), + dtype=[np.float64, np.float32], + reset=False) + # XXX: input data validation is performed again in + # non_negative_factorization. W, _, n_iter_ = non_negative_factorization( X=X, W=None, H=self.components_, n_components=self.n_components_, init=self.init, update_H=False, solver=self.solver, diff --git a/sklearn/decomposition/_pca.py b/sklearn/decomposition/_pca.py index 64c583b2cd2..124f876fc46 100644 --- a/sklearn/decomposition/_pca.py +++ b/sklearn/decomposition/_pca.py @@ -21,7 +21,6 @@ from scipy.sparse.linalg import svds from ._base import _BasePCA from ..utils import check_random_state -from ..utils import check_array from ..utils.extmath import fast_logdet, randomized_svd, svd_flip from ..utils.extmath import stable_cumsum from ..utils.validation import check_is_fitted @@ -583,7 +582,7 @@ class PCA(_BasePCA): """ check_is_fitted(self) - X = check_array(X) + X = self._validate_data(X, dtype=[np.float64, np.float32], reset=False) Xr = X - self.mean_ n_features = X.shape[1] precision = self.get_precision() diff --git a/sklearn/decomposition/_sparse_pca.py b/sklearn/decomposition/_sparse_pca.py index b850182c242..7b323f037b1 100644 --- a/sklearn/decomposition/_sparse_pca.py +++ b/sklearn/decomposition/_sparse_pca.py @@ -4,7 +4,7 @@ import numpy as np -from ..utils import check_random_state, check_array +from ..utils import check_random_state from ..utils.validation import check_is_fitted from ..utils.validation import _deprecate_positional_args from ..linear_model import ridge_regression @@ -197,7 +197,7 @@ class SparsePCA(TransformerMixin, BaseEstimator): """ check_is_fitted(self) - X = check_array(X) + X = self._validate_data(X, reset=False) X = X - self.mean_ U = ridge_regression(self.components_.T, X.T, self.ridge_alpha, diff --git a/sklearn/decomposition/_truncated_svd.py b/sklearn/decomposition/_truncated_svd.py index 20fb1ac03e5..f04d7975614 100644 --- a/sklearn/decomposition/_truncated_svd.py +++ b/sklearn/decomposition/_truncated_svd.py @@ -218,8 +218,8 @@ class TruncatedSVD(TransformerMixin, BaseEstimator): X_new : ndarray of shape (n_samples, n_components) Reduced version of X. This will always be a dense array. """ - X = check_array(X, accept_sparse=['csr', 'csc']) check_is_fitted(self) + X = self._validate_data(X, accept_sparse=['csr', 'csc'], reset=False) return safe_sparse_dot(X, self.components_.T) def inverse_transform(self, X): diff --git a/sklearn/decomposition/tests/test_online_lda.py b/sklearn/decomposition/tests/test_online_lda.py index 3e8788d1010..3d64c9889a8 100644 --- a/sklearn/decomposition/tests/test_online_lda.py +++ b/sklearn/decomposition/tests/test_online_lda.py @@ -137,21 +137,6 @@ def test_lda_fit_transform(method): assert_array_almost_equal(X_fit, X_trans, 4) -def test_lda_partial_fit_dim_mismatch(): - # test `n_features` mismatch in `partial_fit` - rng = np.random.RandomState(0) - n_components = rng.randint(3, 6) - n_col = rng.randint(6, 10) - X_1 = np.random.randint(4, size=(10, n_col)) - X_2 = np.random.randint(4, size=(10, n_col + 1)) - lda = LatentDirichletAllocation(n_components=n_components, - learning_offset=5., total_samples=20, - random_state=rng) - lda.partial_fit(X_1) - with pytest.raises(ValueError, match=r"^The provided data has"): - lda.partial_fit(X_2) - - def test_invalid_params(): # test `_check_params` method X = np.ones((5, 10)) @@ -190,20 +175,6 @@ def test_lda_no_component_error(): lda.perplexity(X) -def test_lda_transform_mismatch(): - # test `n_features` mismatch in partial_fit and transform - rng = np.random.RandomState(0) - X = rng.randint(4, size=(20, 10)) - X_2 = rng.randint(4, size=(10, 8)) - - n_components = rng.randint(3, 6) - lda = LatentDirichletAllocation(n_components=n_components, - random_state=rng) - lda.partial_fit(X) - with pytest.raises(ValueError, match=r"^The provided data has"): - lda.partial_fit(X_2) - - @if_safe_multiprocessing_with_blas @pytest.mark.parametrize('method', ('online', 'batch')) def test_lda_multi_jobs(method): diff --git a/sklearn/tests/test_common.py b/sklearn/tests/test_common.py index 50e5ed81410..b84b66d1fb9 100644 --- a/sklearn/tests/test_common.py +++ b/sklearn/tests/test_common.py @@ -289,7 +289,6 @@ N_FEATURES_IN_AFTER_FIT_MODULES_TO_IGNORE = { 'compose', 'covariance', 'cross_decomposition', - 'decomposition', 'discriminant_analysis', 'ensemble', 'feature_extraction', @@ -324,4 +323,5 @@ N_FEATURES_IN_AFTER_FIT_ESTIMATORS = [ @pytest.mark.parametrize("estimator", N_FEATURES_IN_AFTER_FIT_ESTIMATORS, ids=_get_check_estimator_ids) def test_check_n_features_in_after_fitting(estimator): + _set_checking_parameters(estimator) check_n_features_in_after_fitting(estimator.__class__.__name__, estimator)