Repository: scikit-learn/scikit-learn
Pull request: BUG Fixes stacker compatibility with estimators without n_features_in_
<!--
Thanks for contributing a pull request! Please ensure you have taken a look at
the contribution guidelines: https://github.com/scikit-learn/scikit-learn/blob/master/CONTRIBUTING.md#pull-request-checklist
-->

#### Reference Issues/PRs
<!--
Example: Fixes #1234. See also #3456.
Please use keywords (e.g., Fixes) to create link to the issues or pull requests
you resolved, so that they will automatically be closed when your pull request
is merged. See https://github.com/blog/1506-closing-issues-via-pull-requests
-->
Fixes https://github.com/scikit-learn/scikit-learn/issues/17353

#### What does this implement/fix? Explain your changes.
Makes `n_features_in_` a property that depends on the first estimator.


<!--
Please be aware that we are a loose team of volunteers so patience is
necessary; assistance handling other issues is very welcome. We value
all user contributions, no matter how minor they are. If we are slow to
review, either the pull request needs some benchmarking, tinkering,
convincing, etc. or more likely the reviewers are simply busy. In either
case, we ask for your understanding during the review process.
For more information, see our FAQ on this topic:
http://scikit-learn.org/dev/faq.html#why-is-my-pull-request-not-getting-any-attention.

Thanks for contributing!
-->


Problem statement:
n_features_in_ in StackingRegressor
In updating to version _0.23.1_, the behavior of ```StackingRegressor``` changed with the **n_features_in_** attribute in line 149 of ```_stacking.py```. Namely, ```self.estimators_[0].n_features_in_``` requires the first estimator to have this attribute, i.e., it currently precludes an estimator such as the LightGBM ```LGBMRegressor``` from being the first estimator (see code below).  This is somewhat unexpected behavior, and it is potentially prohibitive if someone was not using an estimator with this attribute in the first stacking layer.

```
import lightgbm as lgb
from sklearn.model_selection import train_test_split
from sklearn.datasets import load_diabetes
from sklearn.linear_model import RidgeCV
from sklearn.ensemble import RandomForestRegressor
from sklearn.ensemble import StackingRegressor

X, y = load_diabetes(return_X_y=True)
estimators = [
    ('gbm', lgb.LGBMRegressor()), ('lr', RidgeCV())
]
reg = StackingRegressor(
    estimators=estimators,
    final_estimator=RandomForestRegressor(n_estimators=10,
                                          random_state=42)
)

X_train, X_test, y_train, y_test = train_test_split(
    X, y, random_state=42
)
reg.fit(X_train, y_train).score(X_test, y_test)
```

The returned error is ```AttributeError: 'LGBMRegressor' object has no attribute 'n_features_in_'```. If one permutes ```LGBMRegressor``` with ```RidgeCV```, then there is clearly no issue (and I obtain the score 0.37835750181106165).

Hints from issue discussion:

Commit message: TST Adds more tests

Patch to review:
diff --git a/doc/whats_new/v0.23.rst b/doc/whats_new/v0.23.rst
index b089b4d5a1d78..e3a945d811e8e 100644
--- a/doc/whats_new/v0.23.rst
+++ b/doc/whats_new/v0.23.rst
@@ -2,6 +2,21 @@
 
 .. currentmodule:: sklearn
 
+.. _changes_0_23_2:
+
+Version 0.23.2
+==============
+
+Changelog
+---------
+
+:mod:`sklearn.ensemble`
+.......................
+
+- |Fix| Fixes :class:`ensemble.StackingClassifier` and
+  :class:`ensemble.StackingRegressor` compatibility with estimators that
+  does not define `n_features_in_`. :pr:`17357` by `Thomas Fan`_.
+
 .. _changes_0_23_1:
 
 Version 0.23.1
diff --git a/sklearn/ensemble/_stacking.py b/sklearn/ensemble/_stacking.py
index 73aa55c0575a7..bc430a32d5398 100644
--- a/sklearn/ensemble/_stacking.py
+++ b/sklearn/ensemble/_stacking.py
@@ -13,6 +13,7 @@
 from ..base import clone
 from ..base import ClassifierMixin, RegressorMixin, TransformerMixin
 from ..base import is_classifier, is_regressor
+from ..exceptions import NotFittedError
 from ..utils._estimator_html_repr import _VisualBlock
 
 from ._base import _fit_single_estimator
@@ -146,7 +147,6 @@ def fit(self, X, y, sample_weight=None):
             delayed(_fit_single_estimator)(clone(est), X, y, sample_weight)
             for est in all_estimators if est != 'drop'
         )
-        self.n_features_in_ = self.estimators_[0].n_features_in_
 
         self.named_estimators_ = Bunch()
         est_fitted_idx = 0
@@ -197,6 +197,15 @@ def fit(self, X, y, sample_weight=None):
 
         return self
 
+    @property
+    def n_features_in_(self):
+        """Number of features seen during :term:`fit`."""
+        try:
+            check_is_fitted(self)
+        except NotFittedError:
+            raise AttributeError("n_features_in_ not defined before fitting")
+        return self.estimators_[0].n_features_in_
+
     def _transform(self, X):
         """Concatenate and return the predictions of the estimators."""
         check_is_fitted(self)
diff --git a/sklearn/ensemble/tests/test_stacking.py b/sklearn/ensemble/tests/test_stacking.py
index f8a3f290e96b5..0577605fc3159 100644
--- a/sklearn/ensemble/tests/test_stacking.py
+++ b/sklearn/ensemble/tests/test_stacking.py
@@ -17,6 +17,8 @@
 from sklearn.datasets import load_iris
 from sklearn.datasets import load_diabetes
 from sklearn.datasets import load_breast_cancer
+from sklearn.datasets import make_regression
+from sklearn.datasets import make_classification
 
 from sklearn.dummy import DummyClassifier
 from sklearn.dummy import DummyRegressor
@@ -491,3 +493,24 @@ def test_stacking_cv_influence(stacker, X, y):
     with pytest.raises(AssertionError, match='Not equal'):
         assert_allclose(stacker_cv_3.final_estimator_.coef_,
                         stacker_cv_5.final_estimator_.coef_)
+
+
+@pytest.mark.parametrize("make_dataset, Stacking, Regression", [
+    (make_classification, StackingClassifier, LogisticRegression),
+    (make_regression, StackingRegressor, LinearRegression)
+])
+def test_stacking_without_n_features_in(make_dataset, Stacking, Regression):
+    # Stacking supports estimators without `n_features_in_`. Regression test
+    # for #17353
+
+    class MyLR(Regression):
+        """Regresion without n_features_in_"""
+        def fit(self, X, y):
+            super().fit(X, y)
+            del self.n_features_in_
+
+    X, y = make_dataset(random_state=0, n_samples=100)
+    stack = Stacking(estimators=[('lr', MyLR())])
+
+    # Does not raise
+    stack.fit(X, y)


Code review comments:
diff_hunk: @@ -491,3 +493,24 @@ def test_stacking_cv_influence(stacker, X, y):
     with pytest.raises(AssertionError, match='Not equal'):
         assert_allclose(stacker_cv_3.final_estimator_.coef_,
                         stacker_cv_5.final_estimator_.coef_)
+
+
+@pytest.mark.parametrize("make_dataset, Stacking, Regression", [
+    (make_classification, StackingClassifier, LogisticRegression),
+    (make_regression, StackingRegressor, LinearRegression)
+])
+def test_stacking_without_n_features_in(make_dataset, Stacking, Regression):
+    # Stacking supports estimators without `n_features_in_`. Regression test
+    # for #17353
+
+    class MyLR(Regression):
+        """Regresion without n_features_in_"""
+        def fit(self, X, y):
+            super().fit(X, y)
+            del self.n_features_in_
+
+    X, y = make_dataset(random_state=0, n_samples=100)
+    stack = Stacking(estimators=[('lr', MyLR())])
+
+    # Does not raise
+    stack.fit(X, y)
path: sklearn/ensemble/tests/test_stacking.py
text: @user1:
maybe assert an AttributeError when it's accessed after git