Repository: sktime/sktime

Issue:
[ENH] `scipy.curve_fit` based forecaster
**Is your feature request related to a problem? Please describe.**
In scipy there exist the `curve_fit` method. This takes a function, x, and y data and some initial parameters. If x are time series indexes this can be used to perform forecasting: 


**Describe the solution you'd like**
Example usage would looks like that:
```python
def sine_3(x,amplitude, phase, offset, amplitude2, amplitude3, phase2):
    sbase = np.sin(x * (1 / 365 / 24 * np.pi * 4) + phase) * amplitude + offset 
    s1 = amplitude2 * x * (np.sin(x* (1 / 365 / 24 * np.pi * 2) + (365* 24* np.pi)+ phase2) + 1)
    s2 = amplitude3 * x * (np.sin(x* (1 / 365 / 24 * np.pi * 2) + phase2) - 1)
    return sbase + s1 + s2


function = FunctionForecaster(function=sine_3, initial_params=[3 * np.std(rolling_mean) / (2 ** 0.5), 
                                         0, 
                                         rolling_mean.mean(),
                                         10,
                                         1,
                                         1])

function.fit(rolling_mean)
function.predict(fh=ForecastingHorizon(y_test.index, is_relative=False))
```

During the `fit`, `curve_fit` is used to detect the best parameters. During `predict`, the found parameters and the fh are used to forecast the time series. 



**Describe alternatives you've considered**
* Implement a new forecaster.
* Extend TrendForecaster (idea from @fkiraly)


**Additional context**

The fit code would look as follows: 

```python
def fit(y, X, fh):
        t = ForecastingHorizon(y.index, is_relative=False).to_relative(pd.DatetimeIndex([self._y.index[0]], name=self.cutoff.name, freq=self.cutoff.freq))
    
        self.params_ = curve_fit(self.function, list(t), y.values, self.initial_params)
        return self
```
and the predict code as:
```python

def predict(fh, X):
        t = fh.to_relative(pd.DatetimeIndex([self._y.index[0]], name=self.cutoff.name, freq=self.cutoff.freq))
        return pd.Series(self.function(np.array(t), 
                             *self.params_[0]), index=pd.DatetimeIndex(fh.to_absolute(self.cutoff)))

```

Note using `[self._y.index[0]]` as cutoff  in `to_relative` has the effect that the x values used in the function are natural numbers.


Discussion:

Resolving patch:
diff --git a/sktime/forecasting/trend/__init__.py b/sktime/forecasting/trend/__init__.py
--- a/sktime/forecasting/trend/__init__.py
+++ b/sktime/forecasting/trend/__init__.py
@@ -6,8 +6,10 @@
     "TrendForecaster",
     "PolynomialTrendForecaster",
     "STLForecaster",
+    "CurveFitForecaster",
 ]
 
+from sktime.forecasting.trend._curve_fit_forecaster import CurveFitForecaster
 from sktime.forecasting.trend._polynomial_trend_forecaster import (
     PolynomialTrendForecaster,
 )
diff --git a/sktime/forecasting/trend/_curve_fit_forecaster.py b/sktime/forecasting/trend/_curve_fit_forecaster.py
new file mode 100644
--- /dev/null
+++ b/sktime/forecasting/trend/_curve_fit_forecaster.py
@@ -0,0 +1,220 @@
+# !/usr/bin/env python3 -u
+# copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
+"""Implements CurveFitForecaster."""
+
+__author__ = ["benheid"]
+__all__ = ["CurveFitForecaster"]
+
+import pandas as pd
+from scipy.optimize import curve_fit
+
+from sktime.forecasting.base import BaseForecaster
+from sktime.forecasting.trend._util import _get_X_numpy_int_from_pandas
+
+
+class CurveFitForecaster(BaseForecaster):
+    """The CurveFitForecaster takes a function and fits it by using scipy curve_fit.
+
+    The CurveFitForecaster uses the scipy curve_fit method to determine the optimal
+    parameters for a given function.
+
+    If the index is an integer index, it directly uses the index values.
+    If the index is a `pd.DatetimeIndex` or a `pd.PeriodIndex`, the index values
+    are transformed into floats using two distinct approaches:
+
+    1. For a `pd.DatetimeIndex`, it calculates the number of days since 1970-01-01.
+       For a `pd.PeriodIndex`, it computes the number of (full) periods since
+        1970-01-01.
+
+    2. For a `pd.DatetimeIndex`, it calculates the number of days since the first
+       index value.
+       For a  `pd.PeriodIndex`, it calculates or the number of (dull) periods since
+       the first index value.
+    Furthermore, the difference between the index values can be normalised by
+    setting the difference between the first and the second index value to one.
+
+    In `fit`
+    1. The index of the input time series is transformed to a list of floats.
+    2. The scipy curve_fit is called using the list of floats as x values,
+       and the time series values as y values.
+    In `predict`
+    1. The ForecastingHorizon is transformed to a list of floats.
+    2. The list of floats is passed together with the fitted parameters to the
+       function to provide the forecast.
+
+    Parameters
+    ----------
+    function: Callable[[Iterable[float], ...], Iterable[float]]
+        The function that should be fitted and used to make forecasts.
+        The signature of the functions is `function(x, ...)`.
+        It takes the independent variables as first argument and the parametrs
+        to fit as separate remaining arguments.
+        See scipy.optimize.curve_fit for more information.
+    curve_fit_params: dict, default=None
+        Additional parameters that should be passed to the curve_fit method.
+        See scipy.optimize.curve_fit for more information.
+    origin: {"unix_zero", "first_index"}, default="unix_zero"
+        The origin of the time series index.
+    normalise_index: bool, default=False
+        If True, the differences between the index values are normalised by
+        setting the difference between the first and second index value to one.
+
+    Examples
+    --------
+    >>> from sktime.forecasting.trend import CurveFitForecaster
+    >>> from sktime.datasets import load_airline
+    >>> y = load_airline()
+    >>> def linear_function(x, a, b):
+    ...     return a * x + b
+    >>> forecaster = CurveFitForecaster(function=linear_function,
+    ...                                 curve_fit_params={"p0":[-1, 1]})
+    >>> forecaster.fit(y)
+    CurveFitForecaster(...)
+    >>> y_pred = forecaster.predict(fh=[1, 2, 3])
+    """
+
+    _tags = {
+        "scitype:y": "univariate",
+        "y_inner_mtype": "pd.Series",
+        "X_inner_mtype": "pd.DataFrame",
+        "ignores-exogeneous-X": True,
+        "requires-fh-in-fit": False,
+    }
+
+    def __init__(
+        self,
+        function,
+        curve_fit_params=None,
+        origin="unix_zero",
+        normalise_index=False,
+    ):
+        self.function = function
+        self.curve_fit_params = curve_fit_params
+        self._curve_fit_params = (
+            curve_fit_params if curve_fit_params is not None else {}
+        )
+        if origin not in ["unix_zero", "first_index"]:
+            raise ValueError(
+                f"origin must be 'unix_zero' or 'first_index', but found {origin}"
+            )
+        self.origin = origin
+        self.normalise_index = normalise_index
+        super().__init__()
+
+    def _fit(self, y, X=None, fh=None):
+        """Fit forecaster to training data.
+
+        Writes to self:
+            Sets fitted model attributes ending in "_".
+
+        Parameters
+        ----------
+        y : pd.DataFrame
+            time series to which to fit the forecaster.
+        fh : guaranteed to be ForecastingHorizon or None, optional (default=None)
+            The forecasting horizon with the steps ahead to predict.
+            Ignored in fit
+        X : pd.DataFrame, optional (default=None)
+            Ignored in fit
+
+        Returns
+        -------
+        self : reference to self
+        """
+        x = _get_X_numpy_int_from_pandas(y.index)[:, 0]
+        start = _get_X_numpy_int_from_pandas(self._y.index[:1])[:, 0]
+
+        if self.origin == "first_index":
+            x = x - start
+        self.delta_ = None
+        if self.normalise_index:
+            if len(x) > 1:
+                self.delta_ = x[1] - x[0]
+                x = x // self.delta_
+
+        self.params_ = curve_fit(self.function, x, y.values, **self._curve_fit_params)
+        return self
+
+    def _predict(self, fh, X=None):
+        """Forecast time series at future horizon.
+
+        State required:
+            Requires state to be "fitted".
+
+        Accesses in self:
+            Fitted model attributes ending in "_"
+            self.cutoff
+
+        Parameters
+        ----------
+        fh : guaranteed to be ForecastingHorizon,
+            The forecasting horizon with the steps ahead to to predict.
+        X : pd.DataFrame, optional (default=None)
+            Ignored in predict
+
+        Returns
+        -------
+        y_pred : pd.DataFrame
+            Point predictions
+        """
+        fh = self.fh.to_absolute_index(self.cutoff)
+        x = _get_X_numpy_int_from_pandas(fh)[:, 0]
+        start = _get_X_numpy_int_from_pandas(self._y.index[:1])[:, 0]
+
+        if self.origin == "first_index":
+            x = x - start
+        if self.delta_ is not None:
+            x = x // self.delta_
+
+        return pd.Series(
+            self.function(x, *self.params_[0]),
+            index=fh,
+            name=self._y.name,
+        )
+
+    @classmethod
+    def get_test_params(cls, parameter_set="default"):
+        """Return testing parameter settings for the estimator.
+
+        Parameters
+        ----------
+        parameter_set : str, default="default"
+            Name of the set of test parameters to return, for use in tests. If no
+            special parameters are defined for a value, will return `"default"` set.
+            There are currently no reserved values for forecasters.
+
+        Returns
+        -------
+        params : dict or list of dict, default = {}
+            Parameters to create testing instances of the class.
+        """
+        params1 = {
+            "function": _test_function,
+            "curve_fit_params": {
+                "p0": [1, 1],
+                "sigma": None,
+                "absolute_sigma": True,
+                "check_finite": True,
+                "bounds": [(-100, -100), (100, 100)],
+                "method": "dogbox",
+            },
+        }
+
+        params2 = {
+            "function": _test_function_2,
+            "curve_fit_params": {
+                "method": "trf",
+            },
+            "origin": "first_index",
+            "normalise_index": True,
+        }
+
+        return [params1, params2]
+
+
+def _test_function(x, a, b):
+    return a * x + b
+
+
+def _test_function_2(x, a, b, c):
+    return a * x**2 + b * x + c


Test patch: