diff --git a/sklearn/base.py b/sklearn/base.py index ca957898c42ff..4efbcddc6cc7c 100644 --- a/sklearn/base.py +++ b/sklearn/base.py @@ -6,6 +6,7 @@ import copy import warnings from collections import defaultdict +import numbers import platform import inspect import re @@ -555,6 +556,33 @@ def fit_transform(self, X, y=None, **fit_params): # fit method of arity 2 (supervised transformation) return self.fit(X, y, **fit_params).transform(X) + @property + def n_features_out_(self): + return self._n_features_out + + @n_features_out_.setter + def n_features_out_(self, val): + self._n_features_out = val + + +class ComponentsMixin: + @property + def n_features_out_(self): + if hasattr(self, 'n_components_'): + # n_components could be auto or None + # this is more likely to be an int + n_features = self.n_components_ + elif hasattr(self, 'components_'): + n_features = self.components_.shape[0] + elif (hasattr(self, 'n_components') + and isinstance(self.n_components, numbers.Integral)): + n_features = self.n_components + else: + raise AttributeError( + "{} has no attribute 'n_features_out_'".format( + type(self).__name__)) + return n_features + class DensityMixin: """Mixin class for all density estimators in scikit-learn.""" diff --git a/sklearn/cluster/birch.py b/sklearn/cluster/birch.py index 2593d2cfcc3a5..78ec84aaf6eb7 100644 --- a/sklearn/cluster/birch.py +++ b/sklearn/cluster/birch.py @@ -493,6 +493,8 @@ def _fit(self, X): self.subcluster_centers_ = centroids self._global_clustering(X) + self.n_features_out_ = self.n_clusters + return self def _get_leaves(self): diff --git a/sklearn/cluster/hierarchical.py b/sklearn/cluster/hierarchical.py index 36ccf95253e96..2cbddb49c2fcf 100644 --- a/sklearn/cluster/hierarchical.py +++ b/sklearn/cluster/hierarchical.py @@ -1036,6 +1036,7 @@ def fit(self, X, y=None, **params): """ X = check_array(X, accept_sparse=['csr', 'csc', 'coo'], ensure_min_features=2, estimator=self) + self.n_features_out_ = self.n_clusters return AgglomerativeClustering.fit(self, X.T, **params) @property diff --git a/sklearn/cluster/k_means_.py b/sklearn/cluster/k_means_.py index 8af8cc6873011..b4f70a06a8fb6 100644 --- a/sklearn/cluster/k_means_.py +++ b/sklearn/cluster/k_means_.py @@ -839,6 +839,7 @@ def fit(self, X, y=None, sample_weight=None): """ random_state = check_random_state(self.random_state) + self.n_features_out_ = self.n_clusters n_init = self.n_init if n_init <= 0: raise ValueError("Invalid number of initializations." @@ -1626,6 +1627,7 @@ def fit(self, X, y=None, sample_weight=None): if self.compute_labels: self.labels_, self.inertia_ = \ self._labels_inertia_minibatch(X, sample_weight) + self.n_features_out_ = self.n_clusters return self @@ -1725,6 +1727,7 @@ def partial_fit(self, X, y=None, sample_weight=None): if self.compute_labels: self.labels_, self.inertia_ = _labels_inertia( X, sample_weight, x_squared_norms, self.cluster_centers_) + self.n_features_out_ = self.n_clusters return self diff --git a/sklearn/compose/_column_transformer.py b/sklearn/compose/_column_transformer.py index 6335fd7a4b20d..c3e78c89df599 100644 --- a/sklearn/compose/_column_transformer.py +++ b/sklearn/compose/_column_transformer.py @@ -360,6 +360,18 @@ def get_feature_names(self): trans.get_feature_names()]) return feature_names + @property + def n_features_out_(self): + n_features_out = 0 + for name, trans, column, _ in self._iter(fitted=True): + if trans == 'drop': + continue + elif trans == 'passthrough': + n_features_out += len(column) + else: + n_features_out += trans.n_features_out_ + return n_features_out + def _update_fitted_transformers(self, transformers): # transformers are fitted; excludes 'drop' cases fitted_transformers = iter(transformers) diff --git a/sklearn/compose/tests/test_column_transformer.py b/sklearn/compose/tests/test_column_transformer.py index 094b2769de369..46471c179c7cc 100644 --- a/sklearn/compose/tests/test_column_transformer.py +++ b/sklearn/compose/tests/test_column_transformer.py @@ -662,10 +662,12 @@ def test_column_transformer_get_feature_names(): [('col' + str(i), DictVectorizer(), i) for i in range(2)]) ct.fit(X) assert ct.get_feature_names() == ['col0__a', 'col0__b', 'col1__c'] + assert ct.n_features_out_ == len(ct.get_feature_names()) # passthrough transformers not supported ct = ColumnTransformer([('trans', 'passthrough', [0, 1])]) ct.fit(X) + assert ct.n_features_out_ == 2 assert_raise_message( NotImplementedError, 'get_feature_names is not yet supported', ct.get_feature_names) @@ -682,6 +684,7 @@ def test_column_transformer_get_feature_names(): [('col0', DictVectorizer(), 0), ('col1', 'drop', 1)]) ct.fit(X) assert ct.get_feature_names() == ['col0__a', 'col0__b'] + assert ct.n_features_out_ == len(ct.get_feature_names()) def test_column_transformer_special_strings(): diff --git a/sklearn/decomposition/base.py b/sklearn/decomposition/base.py index e89a05051404b..465e6ef99109a 100644 --- a/sklearn/decomposition/base.py +++ b/sklearn/decomposition/base.py @@ -11,13 +11,14 @@ import numpy as np from scipy import linalg -from ..base import BaseEstimator, TransformerMixin +from ..base import BaseEstimator, TransformerMixin, ComponentsMixin from ..utils import check_array from ..utils.validation import check_is_fitted from abc import ABCMeta, abstractmethod -class _BasePCA(TransformerMixin, BaseEstimator, metaclass=ABCMeta): +class _BasePCA(ComponentsMixin, TransformerMixin, + BaseEstimator, metaclass=ABCMeta): """Base class for PCA methods. Warning: This class should not be used directly. @@ -154,6 +155,6 @@ def inverse_transform(self, X): """ if self.whiten: return np.dot(X, np.sqrt(self.explained_variance_[:, np.newaxis]) * - self.components_) + self.mean_ + self.components_) + self.mean_ else: return np.dot(X, self.components_) + self.mean_ diff --git a/sklearn/decomposition/dict_learning.py b/sklearn/decomposition/dict_learning.py index 05f06edc05934..72178ba4f2acb 100644 --- a/sklearn/decomposition/dict_learning.py +++ b/sklearn/decomposition/dict_learning.py @@ -13,7 +13,7 @@ from scipy import linalg from joblib import Parallel, delayed, effective_n_jobs -from ..base import BaseEstimator, TransformerMixin +from ..base import BaseEstimator, TransformerMixin, ComponentsMixin from ..utils import (check_array, check_random_state, gen_even_slices, gen_batches) from ..utils.extmath import randomized_svd, row_norms @@ -875,7 +875,7 @@ def dict_learning_online(X, n_components=2, alpha=1, n_iter=100, return dictionary.T -class SparseCodingMixin(TransformerMixin): +class SparseCodingMixin(ComponentsMixin, TransformerMixin): """Sparse coding mixin""" def _set_sparse_coding_params(self, n_components, diff --git a/sklearn/decomposition/factor_analysis.py b/sklearn/decomposition/factor_analysis.py index 4fa48d5d0d88f..8f2c06658d2c5 100644 --- a/sklearn/decomposition/factor_analysis.py +++ b/sklearn/decomposition/factor_analysis.py @@ -25,14 +25,14 @@ from scipy import linalg -from ..base import BaseEstimator, TransformerMixin +from ..base import BaseEstimator, ComponentsMixin, TransformerMixin from ..utils import check_array, check_random_state from ..utils.extmath import fast_logdet, randomized_svd, squared_norm from ..utils.validation import check_is_fitted from ..exceptions import ConvergenceWarning -class FactorAnalysis(TransformerMixin, BaseEstimator): +class FactorAnalysis(ComponentsMixin, TransformerMixin, BaseEstimator): """Factor Analysis (FA) A simple linear generative model with Gaussian latent variables. diff --git a/sklearn/decomposition/fastica_.py b/sklearn/decomposition/fastica_.py index dffce0dc0d8bc..902926857204e 100644 --- a/sklearn/decomposition/fastica_.py +++ b/sklearn/decomposition/fastica_.py @@ -14,7 +14,7 @@ import numpy as np from scipy import linalg -from ..base import BaseEstimator, TransformerMixin +from ..base import BaseEstimator, TransformerMixin, ComponentsMixin from ..exceptions import ConvergenceWarning from ..utils import check_array, as_float_array, check_random_state @@ -380,7 +380,7 @@ def g(x, fun_args): return None, W, S -class FastICA(TransformerMixin, BaseEstimator): +class FastICA(ComponentsMixin, TransformerMixin, BaseEstimator): """FastICA: a fast algorithm for Independent Component Analysis. Read more in the :ref:`User Guide `. diff --git a/sklearn/decomposition/kernel_pca.py b/sklearn/decomposition/kernel_pca.py index 1429106495a6e..53b30c0dd5874 100644 --- a/sklearn/decomposition/kernel_pca.py +++ b/sklearn/decomposition/kernel_pca.py @@ -11,12 +11,12 @@ from ..utils.extmath import svd_flip from ..utils.validation import check_is_fitted, check_array from ..exceptions import NotFittedError -from ..base import BaseEstimator, TransformerMixin +from ..base import BaseEstimator, TransformerMixin, ComponentsMixin from ..preprocessing import KernelCenterer from ..metrics.pairwise import pairwise_kernels -class KernelPCA(TransformerMixin, BaseEstimator): +class KernelPCA(ComponentsMixin, TransformerMixin, BaseEstimator): """Kernel Principal component analysis (KPCA) Non-linear dimensionality reduction through the use of kernels (see diff --git a/sklearn/decomposition/nmf.py b/sklearn/decomposition/nmf.py index 0cf663e123861..2709cf7e7cc49 100644 --- a/sklearn/decomposition/nmf.py +++ b/sklearn/decomposition/nmf.py @@ -14,7 +14,7 @@ import numpy as np import scipy.sparse as sp -from ..base import BaseEstimator, TransformerMixin +from ..base import BaseEstimator, TransformerMixin, ComponentsMixin from ..utils import check_random_state, check_array from ..utils.extmath import randomized_svd, safe_sparse_dot, squared_norm from ..utils.validation import check_is_fitted, check_non_negative @@ -1070,7 +1070,7 @@ def non_negative_factorization(X, W=None, H=None, n_components=None, return W, H, n_iter -class NMF(TransformerMixin, BaseEstimator): +class NMF(ComponentsMixin, TransformerMixin, BaseEstimator): r"""Non-Negative Matrix Factorization (NMF) Find two non-negative matrices (W, H) whose product approximates the non- diff --git a/sklearn/decomposition/online_lda.py b/sklearn/decomposition/online_lda.py index 430c26e190c3c..3bff4212369fb 100644 --- a/sklearn/decomposition/online_lda.py +++ b/sklearn/decomposition/online_lda.py @@ -16,7 +16,7 @@ from scipy.special import gammaln from joblib import Parallel, delayed, effective_n_jobs -from ..base import BaseEstimator, TransformerMixin +from ..base import BaseEstimator, TransformerMixin, ComponentsMixin from ..utils import (check_random_state, check_array, gen_batches, gen_even_slices) from ..utils.fixes import logsumexp @@ -132,7 +132,8 @@ def _update_doc_distribution(X, exp_topic_word_distr, doc_topic_prior, return (doc_topic_distr, suff_stats) -class LatentDirichletAllocation(TransformerMixin, BaseEstimator): +class LatentDirichletAllocation(ComponentsMixin, TransformerMixin, + BaseEstimator): """Latent Dirichlet Allocation with online variational Bayes algorithm .. versionadded:: 0.17 diff --git a/sklearn/decomposition/sparse_pca.py b/sklearn/decomposition/sparse_pca.py index 50f869fa4b1e8..1d7dab7d96873 100644 --- a/sklearn/decomposition/sparse_pca.py +++ b/sklearn/decomposition/sparse_pca.py @@ -9,7 +9,7 @@ from ..utils import check_random_state, check_array from ..utils.validation import check_is_fitted from ..linear_model import ridge_regression -from ..base import BaseEstimator, TransformerMixin +from ..base import BaseEstimator, TransformerMixin, ComponentsMixin from .dict_learning import dict_learning, dict_learning_online @@ -29,7 +29,7 @@ def _check_normalize_components(normalize_components, estimator_name): ) -class SparsePCA(TransformerMixin, BaseEstimator): +class SparsePCA(ComponentsMixin, TransformerMixin, BaseEstimator): """Sparse Principal Components Analysis (SparsePCA) Finds the set of sparse components that can optimally reconstruct diff --git a/sklearn/decomposition/truncated_svd.py b/sklearn/decomposition/truncated_svd.py index 13511cb7066b7..e938c4dcb8acc 100644 --- a/sklearn/decomposition/truncated_svd.py +++ b/sklearn/decomposition/truncated_svd.py @@ -10,7 +10,7 @@ import scipy.sparse as sp from scipy.sparse.linalg import svds -from ..base import BaseEstimator, TransformerMixin +from ..base import BaseEstimator, TransformerMixin, ComponentsMixin from ..utils import check_array, check_random_state from ..utils.extmath import randomized_svd, safe_sparse_dot, svd_flip from ..utils.sparsefuncs import mean_variance_axis @@ -18,7 +18,7 @@ __all__ = ["TruncatedSVD"] -class TruncatedSVD(TransformerMixin, BaseEstimator): +class TruncatedSVD(ComponentsMixin, TransformerMixin, BaseEstimator): """Dimensionality reduction using truncated SVD (aka LSA). This transformer performs linear dimensionality reduction by means of diff --git a/sklearn/discriminant_analysis.py b/sklearn/discriminant_analysis.py index f6d442fa91bdf..41bda95f33e5c 100644 --- a/sklearn/discriminant_analysis.py +++ b/sklearn/discriminant_analysis.py @@ -552,6 +552,11 @@ def predict_log_proba(self, X): """ return np.log(self.predict_proba(X)) + @property + def n_features_out_(self): + n_components = self.n_components or np.inf + return min(self._max_components, n_components) + class QuadraticDiscriminantAnalysis(ClassifierMixin, BaseEstimator): """Quadratic Discriminant Analysis diff --git a/sklearn/ensemble/_stacking.py b/sklearn/ensemble/_stacking.py index 97f66aa077772..7ca5366e1603f 100644 --- a/sklearn/ensemble/_stacking.py +++ b/sklearn/ensemble/_stacking.py @@ -458,7 +458,12 @@ def fit(self, X, y, sample_weight=None): check_classification_targets(y) self._le = LabelEncoder().fit(y) self.classes_ = self._le.classes_ - return super().fit(X, self._le.transform(y), sample_weight) + super().fit(X, self._le.transform(y), sample_weight) + if len(self.classes_) == 2: + self.n_features_out_ = len(self.estimators_) + else: + self.n_features_out_ = len(self.estimators_) * len(self.classes_) + return self @if_delegate_has_method(delegate='final_estimator_') def predict(self, X, **predict_params): @@ -691,7 +696,9 @@ def fit(self, X, y, sample_weight=None): self : object """ y = column_or_1d(y, warn=True) - return super().fit(X, y, sample_weight) + super().fit(X, y, sample_weight) + self.n_features_out_ = len(self.estimators_) + return self def transform(self, X): """Return the predictions for X for each estimator. diff --git a/sklearn/ensemble/forest.py b/sklearn/ensemble/forest.py index a062c913aadcb..1e67a1d9851f1 100644 --- a/sklearn/ensemble/forest.py +++ b/sklearn/ensemble/forest.py @@ -2196,7 +2196,9 @@ def fit_transform(self, X, y=None, sample_weight=None): super().fit(X, y, sample_weight=sample_weight) self.one_hot_encoder_ = OneHotEncoder(sparse=self.sparse_output) - return self.one_hot_encoder_.fit_transform(self.apply(X)) + res = self.one_hot_encoder_.fit_transform(self.apply(X)) + self.n_features_out_ = res.shape[1] + return res def transform(self, X): """Transform dataset. diff --git a/sklearn/ensemble/voting.py b/sklearn/ensemble/voting.py index 9189600cf074f..f38f3d40289c1 100644 --- a/sklearn/ensemble/voting.py +++ b/sklearn/ensemble/voting.py @@ -269,7 +269,9 @@ def fit(self, X, y, sample_weight=None): self.le_ = LabelEncoder().fit(y) self.classes_ = self.le_.classes_ transformed_y = self.le_.transform(y) - + self.n_features_out_ = len(self.estimators) + if self.voting == 'soft': + self.n_features_out_ *= len(self.classes_) return super().fit(X, transformed_y, sample_weight) def predict(self, X): @@ -449,6 +451,7 @@ def fit(self, X, y, sample_weight=None): self : object """ y = column_or_1d(y, warn=True) + self.n_features_out_ = len(self.estimators) return super().fit(X, y, sample_weight) def predict(self, X): diff --git a/sklearn/feature_extraction/dict_vectorizer.py b/sklearn/feature_extraction/dict_vectorizer.py index 857806c892806..a3b89cf1dbfa9 100644 --- a/sklearn/feature_extraction/dict_vectorizer.py +++ b/sklearn/feature_extraction/dict_vectorizer.py @@ -128,6 +128,7 @@ def fit(self, X, y=None): vocab = {f: i for i, f in enumerate(feature_names)} self.feature_names_ = feature_names + self.n_features_out_ = len(self.feature_names_) self.vocabulary_ = vocab return self @@ -205,6 +206,7 @@ def _transform(self, X, fitting): if fitting: self.feature_names_ = feature_names self.vocabulary_ = vocab + self.n_features_out_ = len(self.feature_names_) return result_matrix diff --git a/sklearn/feature_selection/base.py b/sklearn/feature_selection/base.py index bcd9834189f60..3160292a5e8dc 100644 --- a/sklearn/feature_selection/base.py +++ b/sklearn/feature_selection/base.py @@ -46,6 +46,10 @@ def get_support(self, indices=False): mask = self._get_support_mask() return mask if not indices else np.where(mask)[0] + @property + def n_features_out_(self): + return self.get_support().sum() + @abstractmethod def _get_support_mask(self): """ diff --git a/sklearn/impute/_base.py b/sklearn/impute/_base.py index 8c8b83878bae3..9829b17d1f142 100644 --- a/sklearn/impute/_base.py +++ b/sklearn/impute/_base.py @@ -250,10 +250,12 @@ def fit(self, X, y=None): self.missing_values, fill_value) + self.n_features_out_ = np.sum(~_get_mask(self.statistics_, np.nan)) if self.add_indicator: self.indicator_ = MissingIndicator( missing_values=self.missing_values, error_on_new=False) self.indicator_.fit(X) + self.n_features_out_ += self.indicator_.n_features_out_ else: self.indicator_ = None @@ -598,6 +600,7 @@ def _fit(self, X, y=None): missing_features_info = self._get_missing_features_info(X) self.features_ = missing_features_info[1] + self.n_features_out_ = len(self.features_) return missing_features_info[0] diff --git a/sklearn/impute/_iterative.py b/sklearn/impute/_iterative.py index d870f6ca11f1c..b43d86a89337e 100644 --- a/sklearn/impute/_iterative.py +++ b/sklearn/impute/_iterative.py @@ -625,6 +625,8 @@ def fit_transform(self, X, y=None): if self.add_indicator: Xt = np.hstack((Xt, X_trans_indicator)) + + self.n_features_out_ = Xt.shape[1] return Xt def transform(self, X): diff --git a/sklearn/impute/_knn.py b/sklearn/impute/_knn.py index 96b33009f64e7..7a9e88c2412c5 100644 --- a/sklearn/impute/_knn.py +++ b/sklearn/impute/_knn.py @@ -164,7 +164,7 @@ def fit(self, X, y=None): _check_weights(self.weights) self._fit_X = X self._mask_fit_X = _get_mask(self._fit_X, self.missing_values) - + self.n_features_out_ = np.sum(~np.all(self._mask_fit_X, axis=0)) return self def transform(self, X): diff --git a/sklearn/kernel_approximation.py b/sklearn/kernel_approximation.py index 248f9595c5b95..c174a5487fb8b 100644 --- a/sklearn/kernel_approximation.py +++ b/sklearn/kernel_approximation.py @@ -14,14 +14,14 @@ from scipy.linalg import svd from .base import BaseEstimator -from .base import TransformerMixin +from .base import TransformerMixin, ComponentsMixin from .utils import check_array, check_random_state, as_float_array from .utils.extmath import safe_sparse_dot from .utils.validation import check_is_fitted from .metrics.pairwise import pairwise_kernels, KERNEL_PARAMS -class RBFSampler(TransformerMixin, BaseEstimator): +class RBFSampler(ComponentsMixin, TransformerMixin, BaseEstimator): """Approximates feature map of an RBF kernel by Monte Carlo approximation of its Fourier transform. @@ -125,7 +125,7 @@ def transform(self, X): return projection -class SkewedChi2Sampler(TransformerMixin, BaseEstimator): +class SkewedChi2Sampler(ComponentsMixin, TransformerMixin, BaseEstimator): """Approximates feature map of the "skewed chi-squared" kernel by Monte Carlo approximation of its Fourier transform. @@ -324,7 +324,7 @@ def fit(self, X, y=None): self : object Returns the transformer. """ - check_array(X, accept_sparse='csr') + X = check_array(X, accept_sparse='csr') if self.sample_interval is None: # See reference, figure 2 c) if self.sample_steps == 1: @@ -338,6 +338,7 @@ def fit(self, X, y=None): " you need to provide sample_interval") else: self.sample_interval_ = self.sample_interval + self.n_features_out_ = (self.sample_steps + 1) * X.shape[1] return self def transform(self, X): @@ -429,7 +430,7 @@ def _more_tags(self): return {'stateless': True} -class Nystroem(TransformerMixin, BaseEstimator): +class Nystroem(ComponentsMixin, TransformerMixin, BaseEstimator): """Approximate a kernel map using a subset of the training data. Constructs an approximate feature map for an arbitrary kernel diff --git a/sklearn/manifold/isomap.py b/sklearn/manifold/isomap.py index a1fe5243c6ca2..ec3efcfec76af 100644 --- a/sklearn/manifold/isomap.py +++ b/sklearn/manifold/isomap.py @@ -4,7 +4,7 @@ # License: BSD 3 clause (C) 2011 import numpy as np -from ..base import BaseEstimator, TransformerMixin +from ..base import BaseEstimator, TransformerMixin, ComponentsMixin from ..neighbors import NearestNeighbors, kneighbors_graph from ..utils.deprecation import deprecated from ..utils.validation import check_is_fitted @@ -13,7 +13,7 @@ from ..preprocessing import KernelCenterer -class Isomap(TransformerMixin, BaseEstimator): +class Isomap(ComponentsMixin, TransformerMixin, BaseEstimator): """Isomap Embedding Non-linear dimensionality reduction through Isometric Mapping diff --git a/sklearn/manifold/locally_linear.py b/sklearn/manifold/locally_linear.py index 4b7140d6b5f23..9230555b3bfea 100644 --- a/sklearn/manifold/locally_linear.py +++ b/sklearn/manifold/locally_linear.py @@ -9,7 +9,8 @@ from scipy.sparse import eye, csr_matrix from scipy.sparse.linalg import eigsh -from ..base import BaseEstimator, TransformerMixin, _UnstableArchMixin +from ..base import (BaseEstimator, TransformerMixin, + ComponentsMixin, _UnstableArchMixin) from ..utils import check_random_state, check_array from ..utils.extmath import stable_cumsum from ..utils.validation import check_is_fitted @@ -518,7 +519,7 @@ def locally_linear_embedding( tol=tol, max_iter=max_iter, random_state=random_state) -class LocallyLinearEmbedding(TransformerMixin, +class LocallyLinearEmbedding(ComponentsMixin, TransformerMixin, _UnstableArchMixin, BaseEstimator): """Locally Linear Embedding diff --git a/sklearn/neighbors/graph.py b/sklearn/neighbors/graph.py index da3954ff909c7..8bea2cd122afd 100644 --- a/sklearn/neighbors/graph.py +++ b/sklearn/neighbors/graph.py @@ -330,6 +330,10 @@ def fit_transform(self, X, y=None): """ return self.fit(X).transform(X) + @property + def n_features_out_(self): + return self.n_samples_fit_ + class RadiusNeighborsTransformer(NeighborsBase, RadiusNeighborsMixin, UnsupervisedMixin, TransformerMixin): @@ -467,3 +471,7 @@ def fit_transform(self, X, y=None): The diagonal is always explicit. """ return self.fit(X).transform(X) + + @property + def n_features_out_(self): + return self.n_samples_fit_ diff --git a/sklearn/neighbors/nca.py b/sklearn/neighbors/nca.py index ae8e143ae0d1d..fe063b5231a43 100644 --- a/sklearn/neighbors/nca.py +++ b/sklearn/neighbors/nca.py @@ -17,7 +17,7 @@ from scipy.optimize import minimize from ..utils.extmath import softmax from ..metrics import pairwise_distances -from ..base import BaseEstimator, TransformerMixin +from ..base import BaseEstimator, TransformerMixin, ComponentsMixin from ..preprocessing import LabelEncoder from ..decomposition import PCA from ..utils.multiclass import check_classification_targets @@ -27,7 +27,8 @@ from ..exceptions import ConvergenceWarning -class NeighborhoodComponentsAnalysis(TransformerMixin, BaseEstimator): +class NeighborhoodComponentsAnalysis(ComponentsMixin, TransformerMixin, + BaseEstimator): """Neighborhood Components Analysis Neighborhood Component Analysis (NCA) is a machine learning algorithm for diff --git a/sklearn/neural_network/_rbm.py b/sklearn/neural_network/_rbm.py index efe3aeda951af..53b16eaec112f 100644 --- a/sklearn/neural_network/_rbm.py +++ b/sklearn/neural_network/_rbm.py @@ -14,7 +14,7 @@ from scipy.special import expit # logistic function from ..base import BaseEstimator -from ..base import TransformerMixin +from ..base import TransformerMixin, ComponentsMixin from ..utils import check_array from ..utils import check_random_state from ..utils import gen_even_slices @@ -23,7 +23,7 @@ from ..utils.validation import check_is_fitted -class BernoulliRBM(TransformerMixin, BaseEstimator): +class BernoulliRBM(ComponentsMixin, TransformerMixin, BaseEstimator): """Bernoulli Restricted Boltzmann Machine (RBM). A Restricted Boltzmann Machine with binary visible units and diff --git a/sklearn/pipeline.py b/sklearn/pipeline.py index a58979142ae7c..23a0d69867e50 100644 --- a/sklearn/pipeline.py +++ b/sklearn/pipeline.py @@ -244,6 +244,15 @@ def _final_estimator(self): estimator = self.steps[-1][1] return 'passthrough' if estimator is None else estimator + @property + def _final_non_passthrough_estimator(self): + final_estimator = None + for name, est in reversed(self.steps): + if est not in [None, 'passthrough']: + final_estimator = est + break + return final_estimator + def _log_message(self, step_idx): if not self.verbose: return None @@ -623,6 +632,14 @@ def _pairwise(self): # check if first estimator expects pairwise input return getattr(self.steps[0][1], '_pairwise', False) + @property + def n_features_out_(self): + final_est = self._final_non_passthrough_estimator + if final_est is not None: + return final_est.n_features_out_ + else: + return None + def _name_estimators(estimators): """Generate names for estimators.""" @@ -931,6 +948,10 @@ def fit_transform(self, X, y=None, **fit_params): Xs = np.hstack(Xs) return Xs + @property + def n_features_out_(self): + return sum(trans.n_features_out_ for _, trans, _ in self._iter()) + def _log_message(self, name, idx, total): if not self.verbose: return None diff --git a/sklearn/preprocessing/_discretization.py b/sklearn/preprocessing/_discretization.py index 94fcd50f0270b..c2ec3952c9cd8 100644 --- a/sklearn/preprocessing/_discretization.py +++ b/sklearn/preprocessing/_discretization.py @@ -203,7 +203,9 @@ def fit(self, X, y=None): # Fit the OneHotEncoder with toy datasets # so that it's ready for use after the KBinsDiscretizer is fitted self._encoder.fit(np.zeros((1, len(self.n_bins_)), dtype=int)) - + self.n_features_out_ = np.sum(self.n_bins_) + else: + self.n_features_out_ = n_features return self def _validate_n_bins(self, n_features): diff --git a/sklearn/preprocessing/_function_transformer.py b/sklearn/preprocessing/_function_transformer.py index d7ed64b8369bd..7038fe8187b62 100644 --- a/sklearn/preprocessing/_function_transformer.py +++ b/sklearn/preprocessing/_function_transformer.py @@ -114,6 +114,7 @@ def fit(self, X, y=None): if (self.check_inverse and not (self.func is None or self.inverse_func is None)): self._check_inverse_transform(X) + self.n_features_out_ = None return self def transform(self, X): diff --git a/sklearn/preprocessing/data.py b/sklearn/preprocessing/data.py index 4a2c5a4eedbe9..9c70549812bf4 100644 --- a/sklearn/preprocessing/data.py +++ b/sklearn/preprocessing/data.py @@ -196,7 +196,13 @@ def scale(X, axis=0, with_mean=True, with_std=True, copy=True): return X -class MinMaxScaler(TransformerMixin, BaseEstimator): +class _BaseScaler(TransformerMixin, BaseEstimator): + @property + def n_features_out_(self): + return self.scale_.shape[0] + + +class MinMaxScaler(_BaseScaler): """Transforms features by scaling each feature to a given range. This estimator scales and translates each feature individually such @@ -493,7 +499,7 @@ def minmax_scale(X, feature_range=(0, 1), axis=0, copy=True): return X -class StandardScaler(TransformerMixin, BaseEstimator): +class StandardScaler(_BaseScaler): """Standardize features by removing the mean and scaling to unit variance The standard score of a sample `x` is calculated as: @@ -821,7 +827,7 @@ def _more_tags(self): return {'allow_nan': True} -class MaxAbsScaler(TransformerMixin, BaseEstimator): +class MaxAbsScaler(_BaseScaler): """Scale each feature by its maximum absolute value. This estimator scales and translates each feature individually such @@ -1050,7 +1056,7 @@ def maxabs_scale(X, axis=0, copy=True): return X -class RobustScaler(TransformerMixin, BaseEstimator): +class RobustScaler(_BaseScaler): """Scale features using statistics that are robust to outliers. This Scaler removes the median and scales the data according to @@ -1473,6 +1479,7 @@ def fit(self, X, y=None): self.include_bias) self.n_input_features_ = n_features self.n_output_features_ = sum(1 for _ in combinations) + self.n_features_out_ = self.n_output_features_ return self def transform(self, X): @@ -1773,7 +1780,8 @@ def fit(self, X, y=None): ---------- X : array-like """ - check_array(X, accept_sparse='csr') + X = check_array(X, accept_sparse='csr') + self.n_features_out_ = X.shape[1] return self def transform(self, X, copy=None): @@ -1907,7 +1915,8 @@ def fit(self, X, y=None): ---------- X : array-like """ - check_array(X, accept_sparse='csr') + X = check_array(X, accept_sparse='csr') + self.n_features_out_ = X.shape[1] return self def transform(self, X, copy=None): @@ -1995,8 +2004,13 @@ def fit(self, K, y=None): .format(K.shape[0], K.shape[1])) n_samples = K.shape[0] + if K.shape[1] != n_samples: + raise ValueError( + "KernelCenterer requires square kernel" + "matrix for training, got kernel of shape {}".format(K.shape)) self.K_fit_rows_ = np.sum(K, axis=0) / n_samples self.K_fit_all_ = self.K_fit_rows_.sum() / n_samples + self.n_features_out_ = n_samples return self def transform(self, K, copy=True): @@ -2316,7 +2330,7 @@ def fit(self, X, y=None): self._sparse_fit(X, rng) else: self._dense_fit(X, rng) - + self.n_features_out_ = X.shape[1] return self def _transform_col(self, X_col, quantiles, inverse): @@ -2770,7 +2784,7 @@ def _fit(self, X, y=None, force_transform=False): X = self._scaler.fit_transform(X) else: self._scaler.fit(X) - + self.n_features_out_ = X.shape[1] return X def transform(self, X): diff --git a/sklearn/random_projection.py b/sklearn/random_projection.py index 97597dd330e31..22b30234329fc 100644 --- a/sklearn/random_projection.py +++ b/sklearn/random_projection.py @@ -33,7 +33,7 @@ import numpy as np import scipy.sparse as sp -from .base import BaseEstimator, TransformerMixin +from .base import BaseEstimator, TransformerMixin, ComponentsMixin from .utils import check_random_state from .utils.extmath import safe_sparse_dot @@ -289,7 +289,8 @@ def sparse_random_matrix(n_components, n_features, density='auto', return np.sqrt(1 / density) / np.sqrt(n_components) * components -class BaseRandomProjection(TransformerMixin, BaseEstimator, metaclass=ABCMeta): +class BaseRandomProjection(ComponentsMixin, + TransformerMixin, BaseEstimator, metaclass=ABCMeta): """Base class for random projections. Warning: This class should not be used directly. diff --git a/sklearn/tests/test_pipeline.py b/sklearn/tests/test_pipeline.py index e02b5ef96b7b0..03bcc52289d76 100644 --- a/sklearn/tests/test_pipeline.py +++ b/sklearn/tests/test_pipeline.py @@ -20,6 +20,7 @@ from sklearn.utils.testing import assert_array_equal from sklearn.utils.testing import assert_array_almost_equal from sklearn.utils.testing import assert_no_warnings +from sklearn.utils.validation import check_array from sklearn.base import clone, BaseEstimator from sklearn.pipeline import Pipeline, FeatureUnion, make_pipeline, make_union @@ -93,6 +94,7 @@ def __init__(self, mult=1): self.mult = mult def fit(self, X, y): + self.n_features_out_ = check_array(X).shape[1] return self def transform(self, X): @@ -466,6 +468,7 @@ def test_feature_union(): fs.fit(X, y) X_transformed = fs.transform(X) assert X_transformed.shape == (X.shape[0], 3) + assert fs.n_features_out_ == X_transformed.shape[1] # check if it does the expected thing assert_array_almost_equal(X_transformed[:, :-1], svd.fit_transform(X)) @@ -527,13 +530,19 @@ def test_make_union_kwargs(): ) -def test_pipeline_transform(): +@pytest.mark.parametrize('add_passthrough', [True, False]) +def test_pipeline_transform(add_passthrough): # Test whether pipeline works with a transformer at the end. # Also test pipeline.transform and pipeline.inverse_transform iris = load_iris() X = iris.data pca = PCA(n_components=2, svd_solver='full') - pipeline = Pipeline([('pca', pca)]) + if add_passthrough: + pipeline = Pipeline([('first', 'passthrough'), + ('pca', pca), + ('last', 'passthrough')]) + else: + pipeline = Pipeline([('pca', pca)]) # test transform and fit_transform: X_trans = pipeline.fit(X).transform(X) @@ -545,6 +554,7 @@ def test_pipeline_transform(): X_back = pipeline.inverse_transform(X_trans) X_back2 = pca.inverse_transform(X_trans) assert_array_almost_equal(X_back, X_back2) + assert pipeline.n_features_out_ == X_trans.shape[1] def test_pipeline_fit_transform(): @@ -699,6 +709,7 @@ def make(): assert_array_equal([[exp]], pipeline.fit_transform(X, y)) assert_array_equal([exp], pipeline.fit(X).predict(X)) assert_array_equal(X, pipeline.inverse_transform([[exp]])) + assert pipeline._final_non_passthrough_estimator is mult5 pipeline = make() pipeline.set_params(last=passthrough) @@ -707,6 +718,7 @@ def make(): assert_array_equal([[exp]], pipeline.fit(X, y).transform(X)) assert_array_equal([[exp]], pipeline.fit_transform(X, y)) assert_array_equal(X, pipeline.inverse_transform([[exp]])) + assert pipeline._final_non_passthrough_estimator is mult3 assert_raise_message(AttributeError, "'str' object has no attribute 'predict'", getattr, pipeline, 'predict') @@ -919,6 +931,7 @@ def test_set_feature_union_step_drop(drop): assert_array_equal([[2, 3]], ft.fit(X).transform(X)) assert_array_equal([[2, 3]], ft.fit_transform(X)) assert ['m2__x2', 'm3__x3'] == ft.get_feature_names() + assert ft.n_features_out_ == len(ft.get_feature_names()) ft.set_params(m2=drop) assert_array_equal([[3]], ft.fit(X).transform(X)) @@ -929,6 +942,7 @@ def test_set_feature_union_step_drop(drop): assert_array_equal([[]], ft.fit(X).transform(X)) assert_array_equal([[]], ft.fit_transform(X)) assert [] == ft.get_feature_names() + assert ft.n_features_out_ == len(ft.get_feature_names()) # check we can change back ft.set_params(m3=mult3) @@ -939,6 +953,7 @@ def test_set_feature_union_step_drop(drop): assert_array_equal([[3]], ft.fit(X).transform(X)) assert_array_equal([[3]], ft.fit_transform(X)) assert ['m3__x3'] == ft.get_feature_names() + assert ft.n_features_out_ == len(ft.get_feature_names()) def test_step_name_validation(): diff --git a/sklearn/utils/estimator_checks.py b/sklearn/utils/estimator_checks.py index fae600f6f3bf5..756dd7bce75ae 100644 --- a/sklearn/utils/estimator_checks.py +++ b/sklearn/utils/estimator_checks.py @@ -27,7 +27,6 @@ from .testing import set_random_state from .testing import SkipTest from .testing import ignore_warnings -from .testing import assert_dict_equal from .testing import create_memmap_backed_data from . import is_scalar_nan from ..discriminant_analysis import LinearDiscriminantAnalysis @@ -1175,6 +1174,9 @@ def _check_transformer(name, transformer_orig, X, y): else: # check for consistent n_samples assert X_pred.shape[0] == n_samples + if transformer_clone.n_features_out_ is not None: + assert X_pred.shape[1] == transformer_clone.n_features_out_ + if hasattr(transformer, 'transform'): if name in CROSS_DECOMPOSITION: @@ -2161,7 +2163,7 @@ def check_class_weight_balanced_classifiers(name, classifier_orig, X_train, classifier.fit(X_train, y_train) y_pred_balanced = classifier.predict(X_test) assert (f1_score(y_test, y_pred_balanced, average='weighted') > - f1_score(y_test, y_pred, average='weighted')) + f1_score(y_test, y_pred, average='weighted')) @ignore_warnings(category=(DeprecationWarning, FutureWarning)) @@ -2389,8 +2391,8 @@ def param_filter(p): assert init_param.default in [np.float64, np.int64] else: assert (type(init_param.default) in - [str, int, float, bool, tuple, type(None), - np.float64, types.FunctionType, joblib.Memory]) + [str, int, float, bool, tuple, type(None), + np.float64, types.FunctionType, joblib.Memory]) if init_param.name not in params.keys(): # deprecated parameter, not in get_params assert init_param.default is None @@ -2540,7 +2542,7 @@ def check_set_params(name, estimator_orig): curr_params = estimator.get_params(deep=False) try: assert (set(params_before_exception.keys()) == - set(curr_params.keys())) + set(curr_params.keys())) for k, v in curr_params.items(): assert params_before_exception[k] is v except AssertionError: diff --git a/sklearn/utils/tests/test_estimator_checks.py b/sklearn/utils/tests/test_estimator_checks.py index 5891e722e0b71..9c26c83c5eef8 100644 --- a/sklearn/utils/tests/test_estimator_checks.py +++ b/sklearn/utils/tests/test_estimator_checks.py @@ -266,6 +266,7 @@ def fit(self, X, y): class SparseTransformer(BaseEstimator): def fit(self, X, y=None): self.X_shape_ = check_array(X).shape + self.n_features_out_ = self.X_shape_[1] return self def fit_transform(self, X, y=None):