Repository: sktime/sktime

Issue:
[BUG] `HierarchyEnsembleForecaster` returns unexpected predictions if data has only one hierarchy level and forecasters specified by node
**Describe the bug**

The `HierarchyEnsembleForecaster` returns unexpected predictions if the hierarchy has only one level and different forecasters are specified per node (`by=node`).


**To Reproduce**
The code snippet below is similar to the examples in the [documentation](https://www.sktime.net/en/latest/api_reference/auto_generated/sktime.forecasting.compose.HierarchyEnsembleForecaster.html) but with only one level instead of two and only 3 bottom nodes instead of 7.

```python
from sktime.forecasting.compose import HierarchyEnsembleForecaster
from sktime.forecasting.naive import NaiveForecaster
from sktime.forecasting.trend import PolynomialTrendForecaster, TrendForecaster
from sktime.utils._testing.hierarchical import _bottom_hier_datagen
y = _bottom_hier_datagen(
        no_bottom_nodes=3,
        no_levels=1,
        random_seed=123
)
y.groupby(["l1_agg"]).sum().index

# Example of by = 'node'
forecasters = [
    ('trend', TrendForecaster(), [("__total")]),
    ('poly', PolynomialTrendForecaster(degree=2), [('l1_node01')]),
]
forecaster = HierarchyEnsembleForecaster(
                forecasters=forecasters,
                by='node', default=NaiveForecaster()
)
forecaster.fit(y, fh=[1, 2, 3])
y_pred = forecaster.predict()
y_pred
```
*Output:*
<table border="1" class="dataframe">
  <thead>
    <tr style="text-align: right;">
      <th></th>
      <th></th>
      <th>passengers</th>
    </tr>
    <tr>
      <th>l1_agg</th>
      <th>timepoints</th>
      <th></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th rowspan="3" valign="top">__total</th>
      <th>1961-01</th>
      <td>9024.895980</td>
    </tr>
    <tr>
      <th>1961-02</th>
      <td>9075.494395</td>
    </tr>
    <tr>
      <th>1961-03</th>
      <td>9126.092810</td>
    </tr>
    <tr>
      <th rowspan="6" valign="top">l1_node01</th>
      <th>1961-01</th>
      <td>497.671704</td>
    </tr>
    <tr>
      <th>1961-01</th>
      <td>432.000000</td>
    </tr>
    <tr>
      <th>1961-02</th>
      <td>501.352085</td>
    </tr>
    <tr>
      <th>1961-02</th>
      <td>432.000000</td>
    </tr>
    <tr>
      <th>1961-03</th>
      <td>505.046482</td>
    </tr>
    <tr>
      <th>1961-03</th>
      <td>432.000000</td>
    </tr>
    <tr>
      <th rowspan="3" valign="top">l1_node02</th>
      <th>1961-01</th>
      <td>5893.819230</td>
    </tr>
    <tr>
      <th>1961-02</th>
      <td>5893.819230</td>
    </tr>
    <tr>
      <th>1961-03</th>
      <td>5893.819230</td>
    </tr>
    <tr>
      <th rowspan="3" valign="top">l1_node03</th>
      <th>1961-01</th>
      <td>1919.395692</td>
    </tr>
    <tr>
      <th>1961-02</th>
      <td>1919.395692</td>
    </tr>
    <tr>
      <th>1961-03</th>
      <td>1919.395692</td>
    </tr>
  </tbody>
</table>
</div>

**Expected behavior**
*Output:*
<div>
<table border="1" class="dataframe">
  <thead>
    <tr style="text-align: right;">
      <th></th>
      <th></th>
      <th>passengers</th>
    </tr>
    <tr>
      <th>l1_agg</th>
      <th>timepoints</th>
      <th></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th rowspan="3" valign="top">__total</th>
      <th>1961-01</th>
      <td>9024.895980</td>
    </tr>
    <tr>
      <th>1961-02</th>
      <td>9075.494395</td>
    </tr>
    <tr>
      <th>1961-03</th>
      <td>9126.092810</td>
    </tr>
    <tr>
      <th rowspan="3" valign="top">l1_node01</th>
      <th>1961-01</th>
      <td>497.671704</td>
    </tr>
    <tr>
      <th>1961-02</th>
      <td>501.352085</td>
    </tr>
    <tr>
      <th>1961-03</th>
      <td>505.046482</td>
    </tr>
    <tr>
      <th rowspan="3" valign="top">l1_node02</th>
      <th>1961-01</th>
      <td>5893.819230</td>
    </tr>
    <tr>
      <th>1961-02</th>
      <td>5893.819230</td>
    </tr>
    <tr>
      <th>1961-03</th>
      <td>5893.819230</td>
    </tr>
    <tr>
      <th rowspan="3" valign="top">l1_node03</th>
      <th>1961-01</th>
      <td>1919.395692</td>
    </tr>
    <tr>
      <th>1961-02</th>
      <td>1919.395692</td>
    </tr>
    <tr>
      <th>1961-03</th>
      <td>1919.395692</td>
    </tr>
  </tbody>
</table>
</div>



**Additional context**
The predictions for the node `l1_node01` are duplicated. The first occurrences are the predictions from `PolynomialTrendForecaster`. The second occurrences are predictions of the `NaiveForecaster` which is configured as default. If using two hierarchy levels as in the documentation the predictions are as expected (i.e., no duplicates from the default predictor).

I tried to stay close to the example in the doc. However, maybe I am just using the `HierarchyEnsembleForecaster` class wrong.

**Versions**

System:
    python: 3.11.5 | packaged by conda-forge | (main, Aug 27 2023, 03:35:23) [Clang 15.0.7 ]
executable: ../anaconda3/envs/sktime311/bin/python
   machine: macOS-14.0-x86_64-i386-64bit

Python dependencies:
          pip: 23.2.1
       sktime: 0.24.0
      sklearn: 1.3.0
       skbase: 0.5.1
        numpy: 1.25.2
        scipy: 1.11.2
       pandas: 2.0.3
   matplotlib: 3.7.2
       joblib: 1.3.2
        numba: None
  statsmodels: 0.14.0
     pmdarima: 2.0.3
statsforecast: None
      tsfresh: None
      tslearn: None
        torch: None
   tensorflow: None
tensorflow_probability: None


Discussion:

Resolving patch:
diff --git a/sktime/forecasting/compose/_hierarchy_ensemble.py b/sktime/forecasting/compose/_hierarchy_ensemble.py
--- a/sktime/forecasting/compose/_hierarchy_ensemble.py
+++ b/sktime/forecasting/compose/_hierarchy_ensemble.py
@@ -285,7 +285,8 @@ def _get_node_dict(self, z):
                 if counter == 0:
                     nodes = mi
                 else:
-                    nodes.append(mi)
+                    # For nlevels = 2, 'nodes' is pd.Index object (L286)
+                    nodes = nodes.append(mi)
             else:
                 node_l = []
                 for i in range(len(node)):
@@ -495,6 +496,7 @@ def _check_forecasters(self, y, z):
                     raise ValueError("Nodes cannot be empty.")
                 if z.index.nlevels == 2:
                     nodes_ix = pd.Index(nodes)
+                    nodes_t += nodes
                 else:
                     nodes_l = []
                     for i in range(len(nodes)):


Test patch:
diff --git a/sktime/forecasting/compose/tests/test_hierarchy_ensemble.py b/sktime/forecasting/compose/tests/test_hierarchy_ensemble.py
--- a/sktime/forecasting/compose/tests/test_hierarchy_ensemble.py
+++ b/sktime/forecasting/compose/tests/test_hierarchy_ensemble.py
@@ -7,10 +7,11 @@
 import numpy as np
 import pytest
 
+from sktime.base._meta import flatten
 from sktime.datatypes._utilities import get_window
 from sktime.forecasting.compose import HierarchyEnsembleForecaster
 from sktime.forecasting.naive import NaiveForecaster
-from sktime.forecasting.trend import PolynomialTrendForecaster
+from sktime.forecasting.trend import PolynomialTrendForecaster, TrendForecaster
 from sktime.transformations.hierarchical.aggregate import Aggregator
 from sktime.utils._testing.hierarchical import _bottom_hier_datagen, _make_hierarchical
 from sktime.utils.validation._dependencies import _check_soft_dependencies
@@ -145,3 +146,57 @@ def test_hierarchy_ensemble_exog(forecasters):
     estimator_instance.fit(y=y_train, X=X_train, fh=[1, 2, 3])
     estimator_instance.predict(X=X_test)
     estimator_instance.update(y=y_test, X=X_test)
+
+
+@pytest.mark.parametrize(
+    "forecasters",
+    [
+        [
+            ("trend", TrendForecaster(), ["l1_node01"]),
+            ("polytrend", PolynomialTrendForecaster(), ["l1_node02", "l1_node03"]),
+            ("naive", NaiveForecaster(), ["__total"]),
+        ],
+        [
+            (
+                "trend",
+                TrendForecaster(),
+                [("__total"), ("l1_node01"), ("l1_node02"), ("l1_node03")],
+            ),
+        ],
+    ],
+)
+@pytest.mark.parametrize("default", [NaiveForecaster(), None])
+def test_level_one_data(forecasters, default):
+    "Check for data with one level of hierarchy (excluding timepoints level)."
+    agg = Aggregator()
+
+    y = _bottom_hier_datagen(
+        no_bottom_nodes=3,
+        no_levels=1,
+        random_seed=123,
+    )
+
+    forecaster = HierarchyEnsembleForecaster(forecasters, by="node", default=default)
+
+    forecaster.fit(y, fh=[1, 2, 3])
+    actual_pred = forecaster.predict()
+
+    y = agg.fit_transform(y)
+
+    for i in range(len(forecasters)):
+        test_frcstr = forecasters[i][1].clone()
+        df = y[y.index.droplevel(-1).isin(forecaster.fitted_list[i][1])]
+        test_frcstr.fit(df, fh=[1, 2, 3])
+        test_pred = test_frcstr.predict()
+        msg = "Node predictions do not match"
+        assert np.all(actual_pred.loc[test_pred.index] == test_pred), msg
+
+    _, _, nodes = zip(*forecasters)
+    nodes = set(flatten(nodes))
+    if default is not None and len(nodes) != len(y.index.droplevel(-1).unique()):
+        def_frcstr = default
+        df = y[y.index.droplevel(-1).isin(forecaster.fitted_list[-1][1])]
+        def_frcstr.fit(df, fh=[1, 2, 3])
+        def_pred = def_frcstr.predict()
+        msg = "Node default predictions do not match"
+        assert np.all(actual_pred.loc[def_pred.index] == def_pred), msg