Repository: sktime/sktime

Issue:
Implement Guerrero method for Box-Cox lambda estimation 
**Is your feature request related to a problem? Please describe.**
Add Guerrero method for lambda estimation to [BoxCoxTransformer](https://github.com/alan-turing-institute/sktime/blob/main/sktime/transformations/series/boxcox.py).

**Describe the solution you'd like**
You can find an example here: https://otexts.com/fpp3/transformations.html
R code from forecast is available here: https://github.com/robjhyndman/forecast/blob/master/R/guerrero.R


Discussion:
I would like to work on this!
Hi @yashlamba, I think @GuzalBulatova already wanted to work on this. 

I posted some more ideas on how to identify useful issues here: https://github.com/alan-turing-institute/sktime/issues/778

Resolving patch:
diff --git a/sktime/transformations/series/boxcox.py b/sktime/transformations/series/boxcox.py
--- a/sktime/transformations/series/boxcox.py
+++ b/sktime/transformations/series/boxcox.py
@@ -14,10 +14,12 @@
 from scipy.special import inv_boxcox
 from scipy.stats import boxcox_llf
 from scipy.stats import distributions
+from scipy.stats import variation
 from scipy.stats.morestats import _boxcox_conf_interval
 from scipy.stats.morestats import _calc_uniform_order_statistic_medians
 
 from sktime.transformations.base import _SeriesToSeriesTransformer
+from sktime.utils.validation import is_int
 from sktime.utils.validation.series import check_series
 
 
@@ -34,15 +36,20 @@ class BoxCoxTransformer(_SeriesToSeriesTransformer):
 
     _tags = {"transform-returns-same-time-index": True, "univariate-only": True}
 
-    def __init__(self, bounds=None, method="mle"):
+    def __init__(self, bounds=None, method="mle", sp=None):
         self.bounds = bounds
         self.method = method
         self.lambda_ = None
+        self.sp = sp
         super(BoxCoxTransformer, self).__init__()
 
     def fit(self, Z, X=None):
         z = check_series(Z, enforce_univariate=True)
-        self.lambda_ = _boxcox_normmax(z, bounds=self.bounds, method=self.method)
+        if self.method != "guerrero":
+            self.lambda_ = _boxcox_normmax(z, bounds=self.bounds, method=self.method)
+        else:
+            self.lambda_ = _guerrero(z, self.sp, self.bounds)
+
         self._is_fitted = True
         return self
 
@@ -73,8 +80,7 @@ def inverse_transform(self, Z, X=None):
         return np.exp(Z)
 
 
-# TODO replace with scipy version once PR for adding bounds is merged
-def _boxcox_normmax(x, bounds=None, brack=(-2.0, 2.0), method="pearsonr"):
+def _make_boxcox_optimizer(bounds=None, brack=(-2.0, 2.0)):
     # bounds is None, use simple Brent optimisation
     if bounds is None:
 
@@ -92,6 +98,13 @@ def optimizer(func, args):
         def optimizer(func, args):
             return optimize.fminbound(func, bounds[0], bounds[1], args=args)
 
+    return optimizer
+
+
+# TODO replace with scipy version once PR for adding bounds is merged
+def _boxcox_normmax(x, bounds=None, brack=(-2.0, 2.0), method="pearsonr"):
+    optimizer = _make_boxcox_optimizer(bounds, brack)
+
     def _pearsonr(x):
         osm_uniform = _calc_uniform_order_statistic_medians(len(x))
         xvals = distributions.norm.ppf(osm_uniform)
@@ -129,6 +142,60 @@ def _all(x):
     return optimfunc(x)
 
 
+def _guerrero(x, sp, bounds=None):
+    r"""
+    Returns lambda estimated by the Guerrero method [Guerrero].
+    Parameters
+    ----------
+    x : ndarray
+        Input array. Must be 1-dimensional.
+    sp : integer
+        Seasonal periodicity value. Must be an integer >= 2
+    bounds : {None, (float, float)}, optional
+        Bounds on lambda to be used in minimization.
+    Returns
+    -------
+    lambda : float
+        Lambda value that minimizes the coefficient of variation of
+        variances of the time series in different periods after
+        Box-Cox transformation [Guerrero].
+
+    References
+    ----------
+    [Guerrero] V.M. Guerrero, "Time-series analysis supported by Power
+    Transformations ", Journal of Forecasting, Vol. 12, 37-48 (1993)
+    https://doi.org/10.1002/for.3980120104
+    """
+
+    if sp is None or not is_int(sp) or sp < 2:
+        raise ValueError(
+            "Guerrero method requires an integer seasonal periodicity (sp) value >= 2."
+        )
+
+    x = np.asarray(x)
+    if x.ndim != 1:
+        raise ValueError("Data must be 1-dimensional.")
+
+    num_obs = len(x)
+    len_prefix = num_obs % sp
+
+    x_trimmed = x[len_prefix:]
+    x_mat = x_trimmed.reshape((-1, sp))
+    x_mean = np.mean(x_mat, axis=1)
+
+    # [Guerrero, Eq.(5)] uses an unbiased estimation for
+    # the standard deviation
+    x_std = np.std(x_mat, axis=1, ddof=1)
+
+    def _eval_guerrero(lmb, x_std, x_mean):
+        x_ratio = x_std / x_mean ** (1 - lmb)
+        x_ratio_cv = variation(x_ratio)
+        return x_ratio_cv
+
+    optimizer = _make_boxcox_optimizer(bounds)
+    return optimizer(_eval_guerrero, args=(x_std, x_mean))
+
+
 def _boxcox(x, lmbda=None, bounds=None, alpha=None):
     r"""
     Return a dataset transformed by a Box-Cox power transformation.


Test patch:
diff --git a/sktime/transformations/series/tests/test_boxcox.py b/sktime/transformations/series/tests/test_boxcox.py
--- a/sktime/transformations/series/tests/test_boxcox.py
+++ b/sktime/transformations/series/tests/test_boxcox.py
@@ -25,9 +25,38 @@ def test_boxcox_against_scipy():
 
 
 @pytest.mark.parametrize("bounds", [(0, 1), (-1, 0), (-1, 2)])
-@pytest.mark.parametrize("method", ["mle", "pearsonr"])
-def test_lambda_bounds(bounds, method):
+@pytest.mark.parametrize(
+    "method, sp", [("mle", None), ("pearsonr", None), ("guerrero", 5)]
+)
+def test_lambda_bounds(bounds, method, sp):
     y = load_airline()
-    t = BoxCoxTransformer(bounds=bounds, method=method)
+    t = BoxCoxTransformer(bounds=bounds, method=method, sp=sp)
     t.fit(y)
     assert bounds[0] < t.lambda_ < bounds[1]
+
+
+@pytest.mark.parametrize(
+    "bounds, r_lambda",
+    [
+        ((0, 1), 6.61069613518961e-05),
+        ((-1, 0), -0.156975034727656),
+        ((-1, 2), -0.156981228426408),
+    ],
+)
+def test_guerrero_against_r_implementation(bounds, r_lambda):
+    """
+    Testing lambda values estimated by the R implementation of the Guerrero method
+    https://github.com/robjhyndman/forecast/blob/master/R/guerrero.R
+    against the guerrero method in BoxCoxTransformer.
+    R code to generate the hardcoded value for bounds=(-1, 2) used in the test
+    ('Airline.csv' contains the data from 'load_airline()'):
+        airline_file <- read.csv(file = 'Airline.csv')[,c('Passengers')]
+        airline.ts <- ts(airline_file)
+        guerrero(airline.ts, lower=-1, upper=2, nonseasonal.length = 20)
+    Output:
+        -0.156981228426408
+    """
+    y = load_airline()
+    t = BoxCoxTransformer(bounds=bounds, method="guerrero", sp=20)
+    t.fit(y)
+    np.testing.assert_almost_equal(t.lambda_, r_lambda, decimal=4)