Repository: sktime/sktime

Issue:
[BUG] `get_fitted_params` fails on a tuned reconciler forecaster
Reported by @daniel-torresc in https://github.com/sktime/sktime/discussions/4101.

A `get_fitted_params` call on a highly nested combination of reconciliation and tuning fails where it should work as per interface specification.

reproducible example, fails on 3.10, windows, 0.18.0 for me too:

```python
from sktime.utils._testing.hierarchical import _make_hierarchical
from sktime.forecasting.exp_smoothing import ExponentialSmoothing
from sktime.forecasting.trend import PolynomialTrendForecaster
from sktime.forecasting.model_selection import ForecastingGridSearchCV, ExpandingWindowSplitter
from sktime.transformations.hierarchical.aggregate import Aggregator
from sktime.forecasting.reconcile import ReconcilerForecaster
from sktime.forecasting.compose import TransformedTargetForecaster
from sktime.forecasting.compose import ForecastByLevel

y = _make_hierarchical()
agg = Aggregator()
y_agg = agg.fit_transform(y) 

param_grid = [{"forecaster": [ExponentialSmoothing()],
               "forecaster__trend": ['add','mul']
              },
              {"forecaster": [PolynomialTrendForecaster()],
               "forecaster__degree": [1,2]}
              ]

pipe = TransformedTargetForecaster(steps=[
    ("forecaster", ExponentialSmoothing())])

N_cv_fold = 2
step_cv = 1
fh = [1,2]

initial_window_cv_len = len(y_agg.index.get_level_values(2).unique()) - (N_cv_fold - 1) * step_cv - fh[-1]

cv = ExpandingWindowSplitter(
            initial_window = initial_window_cv_len,
            step_length = step_cv,
            fh = fh)

gscv = ForecastingGridSearchCV(
        forecaster=pipe,
        param_grid=param_grid,
        cv=cv,
        n_jobs=-1,
        verbose = 1
        )

gscv_bylevel = ForecastByLevel(gscv, 'local')

reconciler = ReconcilerForecaster(gscv_bylevel, method="ols")

reconciler.fit(y_agg)

reconciler.get_fitted_params()  # here we get the error when accessing ".get_fitted_params()"
```


Discussion:
Hello, do you have any updates on this bug? Is it possible to solve it easily?

Thank you!
that must be telepathy. I was just working on this.
here: #4632
Now that's telepathy! 

I have seen the PR, but I still have some questions. Shouldn't the reconciler access all the best-fitted forecasters for each series? Currently, there is no possibility to access the best forecaster for each one. I believe the get_fitted_params aimed to do that, so I thought this issue was opened to solve that bug. 

Let me know if something doesn't suit you.

Resolving patch:
diff --git a/sktime/base/_meta.py b/sktime/base/_meta.py
--- a/sktime/base/_meta.py
+++ b/sktime/base/_meta.py
@@ -100,9 +100,11 @@ def _get_params(self, attr, deep=True, fitted=False):
 
         if fitted:
             method = "_get_fitted_params"
+            methodd = "get_fitted_params"
             deepkw = {}
         else:
             method = "get_params"
+            methodd = "get_params"
             deepkw = {"deep": deep}
 
         out = getattr(super(), method)(**deepkw)
@@ -111,8 +113,15 @@ def _get_params(self, attr, deep=True, fitted=False):
             estimators = [(x[0], x[1]) for x in estimators]
             out.update(estimators)
             for name, estimator in estimators:
-                if hasattr(estimator, "get_params"):
-                    for key, value in getattr(estimator, method)(**deepkw).items():
+                # checks estimator has the method we want to call
+                cond1 = hasattr(estimator, methodd)
+                # checks estimator is fitted if calling get_fitted_params
+                is_fitted = hasattr(estimator, "is_fitted") and estimator.is_fitted
+                # if we call get_params and not get_fitted_params, this is True
+                cond2 = not fitted or is_fitted
+                # check both conditions together
+                if cond1 and cond2:
+                    for key, value in getattr(estimator, methodd)(**deepkw).items():
                         out["%s__%s" % (name, key)] = value
         return out
 


Test patch:
diff --git a/sktime/forecasting/base/tests/test_base_bugs.py b/sktime/forecasting/base/tests/test_base_bugs.py
new file mode 100644
--- /dev/null
+++ b/sktime/forecasting/base/tests/test_base_bugs.py
@@ -0,0 +1,61 @@
+# -*- coding: utf-8 -*-
+"""Regression tests for bugfixes related to base class related functionality."""
+# copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
+
+import pytest
+
+from sktime.forecasting.compose import ForecastByLevel, TransformedTargetForecaster
+from sktime.forecasting.exp_smoothing import ExponentialSmoothing
+from sktime.forecasting.model_selection import (
+    ExpandingWindowSplitter,
+    ForecastingGridSearchCV,
+)
+from sktime.forecasting.reconcile import ReconcilerForecaster
+from sktime.forecasting.trend import PolynomialTrendForecaster
+from sktime.transformations.hierarchical.aggregate import Aggregator
+from sktime.utils._testing.hierarchical import _make_hierarchical
+from sktime.utils.validation._dependencies import _check_estimator_deps
+
+
+@pytest.mark.skipif(
+    not _check_estimator_deps(ExponentialSmoothing, severity="none"),
+    reason="skip test if required soft dependency not available",
+)
+def test_heterogeneous_get_fitted_params():
+    """Regression test for bugfix #4574, related to get_fitted_params."""
+    y = _make_hierarchical(hierarchy_levels=(2, 2), min_timepoints=7, max_timepoints=7)
+    agg = Aggregator()
+    y_agg = agg.fit_transform(y)
+
+    param_grid = [
+        {
+            "forecaster": [ExponentialSmoothing()],
+            "forecaster__trend": ["add", "mul"],
+        },
+        {
+            "forecaster": [PolynomialTrendForecaster()],
+            "forecaster__degree": [1, 2],
+        },
+    ]
+
+    pipe = TransformedTargetForecaster(steps=[("forecaster", ExponentialSmoothing())])
+
+    N_cv_fold = 2
+    step_cv = 1
+    fh = [1, 2]
+
+    N_t = len(y_agg.index.get_level_values(2).unique())
+    initial_window_cv_len = N_t - (N_cv_fold - 1) * step_cv - fh[-1]
+
+    cv = ExpandingWindowSplitter(
+        initial_window=initial_window_cv_len,
+        step_length=step_cv,
+        fh=fh,
+    )
+
+    gscv = ForecastingGridSearchCV(forecaster=pipe, param_grid=param_grid, cv=cv)
+    gscv_bylevel = ForecastByLevel(gscv, "local")
+    reconciler = ReconcilerForecaster(gscv_bylevel, method="ols")
+
+    reconciler.fit(y_agg)
+    reconciler.get_fitted_params()  # triggers an error pre-fix