import numpy as np
import torch
from exponential_smoothing.model import ETS_Model

"""
ETS (Error,Trend,Seasonality) models for time series forecasting.
All methods have been implemented from chapter 7 of the textbook as a reference.
'Hyndman, Rob J., and George Athanasopoulos. Forecasting: principles
and practice. OTexts, 2014.'
"""

class ETS_ANN(ETS_Model):

    def __init__(self, dep_var, trend=None, seasonal=None, error_type=None, damped=False, seasonal_periods=None, **kwargs):
        """Implementation of ETS method with Additive Errors, No Trend and No Seasonality"""
        
        self.trend = None
        self.damped = False
        self.seasonal = None
        self.seasonal_periods = None
        self.error_type = "add"

        super().set_allowed_kwargs(["alpha", "beta", "phi", "gamma"])
        super().__init__(dep_var, trend=self.trend,  seasonal=self.seasonal, error_type=self.error_type, seasonal_periods=self.seasonal_periods, 
                                damped=self.damped, **kwargs)
        
        initialize_params = ["alpha"]
       
        self.__initialize_params(initialize_params)

        self.residuals = torch.tensor([])
    
    def __calculate_conf_level(self, conf):
        """Calculate the multiplier for prediction interval"""
        self.c = super().calculate_conf_level(conf)

    def __initialize_params(self, initialize_params):
        """Initialize model parameters"""
        super().initialize_params(initialize_params)
        self.initial_level = torch.tensor(self.init_components["level"])
        self.alpha, self.beta, self.gamma, self.phi = torch.tensor(list(self.params.values()), dtype=torch.float32)
    
    def __update_res_variance(self, y, y_hat):
        """Update the residual variance"""
        resid = torch.sub(y, y_hat)
        self.residuals, self.residual_mean, self.residual_variance = super().update_res_variance(self.residuals, resid)

    def __get_confidence_interval(self, h):
        """Get the confidence interval of the predictions"""
        fc_var = torch.add(1 ,torch.mul(torch.square(self.alpha),  h-1))
        fc_var = torch.mul(self.residual_variance, fc_var)
        return torch.mul(self.c, torch.sqrt(fc_var))
    
    def __smooth_level(self, lprev):
        """Calculate the level"""
        self.level = torch.add(lprev, torch.mul(self.alpha, self.error))
    
    def __smooth_error(self, y, y_hat):
        """Calculate error"""
        self.error = torch.sub(y, y_hat)

    def fit(self):
        """
        Fit the model to the data according to the equations:
        y_t = l_{t-1} + e_t
        l_t = l_{t-1} + alpha*e_t
        """

        self.fitted = torch.zeros(size=self.dep_var.shape)

        for index, row in enumerate(self.dep_var):

            if index == 0:
                y_hat = row
                self.level = self.initial_level
                self.error = torch.tensor(0, dtype=torch.float32)
                self.fitted[0] = row
            
            else:
                
                y_hat = self.level
                self.__smooth_error(row, y_hat)
                lprev = self.level
                self.__smooth_level(lprev)
                self.fitted[index] = y_hat
            
            self.__update_res_variance(row, y_hat)
    
    def predict(self, h, confidence = None):
        """
        Predict the next h values of the target variable.

        Arguments:

        *h (int): Future time-steps to forecast
        *confidence (Optional[float]): Confidence level to generate prediction intervals; None or values between (0,1)
        """

        if confidence:
            self.__calculate_conf_level(confidence)
            
        self.forecast = torch.tensor([])
        upper_bounds = torch.tensor([])
        lower_bounds = torch.tensor([])

        for i in range(1,h+1):
            
            step_forecast = self.level

            self.forecast = torch.cat((self.forecast, step_forecast))

            if confidence:
                fc_var = self.__get_confidence_interval(h=i)
                upper_bounds = torch.cat((upper_bounds, torch.add(step_forecast, torch.abs(fc_var))))
                lower_bounds = torch.cat((lower_bounds, torch.sub(step_forecast, torch.abs(fc_var))))
                
        if confidence:
            return self.forecast, (upper_bounds, lower_bounds)
        else:
            return self.forecast

class ETS_AAN(ETS_Model):

    def __init__(self, dep_var, trend=None, seasonal=None, error_type=None, damped=False, seasonal_periods=None, **kwargs):
        """Implementation of ETS method with Additive Errors, Additive Trend and No Seasonality"""
        self.trend = "add"
        self.damped = damped
        self.seasonal = None
        self.seasonal_periods = None
        self.error_type = "add"

        super().set_allowed_kwargs(["alpha", "beta", "phi", "gamma"])
        super().__init__(dep_var, trend=self.trend,  seasonal=self.seasonal, error_type=self.error_type, seasonal_periods=self.seasonal_periods, 
                                damped=self.damped, **kwargs)
        
        initialize_params = ["alpha", "beta"]
        if self.damped:
            initialize_params.append("phi")
        self.__initialize_params(initialize_params)

        self.residuals = torch.tensor([])
    
    def __calculate_conf_level(self, conf):
        """Calculate the multiplier for prediction interval"""

        self.c = super().calculate_conf_level(conf)

    def __initialize_params(self, initialize_params):
        """Initialize model parameters"""

        super().initialize_params(initialize_params)

        self.initial_level = torch.tensor(self.init_components["level"])
        self.initial_trend = torch.tensor(self.init_components["trend"])

        self.alpha, self.beta, self.gamma, self.phi = torch.tensor(list(self.params.values()), dtype=torch.float32)
    
    def __update_res_variance(self, y, y_hat):
        """Update the residual variance"""

        resid = torch.sub(y, y_hat)

        self.residuals, self.residual_mean, self.residual_variance = super().update_res_variance(self.residuals, resid)
        
    def __get_confidence_interval(self, h):
        """Get the confidence interval of the predictions""" 
        
        if not self.damped:
            fc_var = torch.add(torch.square(self.alpha), torch.mul(torch.mul(self.alpha, self.beta), h))
            fc_var = torch.add(fc_var, torch.divide(torch.mul(torch.square(self.beta), h*(2*h-1)), 6))
            fc_var = torch.mul(h-1, fc_var)
            fc_var = torch.add(1, fc_var)
            fc_var = torch.mul(self.residual_variance, fc_var)
        
        if self.damped:

            part1 = torch.mul(torch.square(self.alpha), h-1)

            part3_1 = torch.mul(torch.mul(self.beta, self.phi), h)
            part3_1 = torch.divide(part3_1, torch.square(torch.sub(1, self.phi)))
            part3_2 = torch.add(torch.mul(torch.mul(2, self.alpha), torch.sub(1, self.phi)), torch.mul(self.beta, self.phi))
            part3 = torch.mul(part3_1, part3_2)

            part4_1 =  torch.mul(torch.mul(self.beta, self.phi), torch.sub(1, torch.pow(self.phi, h)))
            part4_1 = torch.divide(part4_1, torch.mul(torch.square(torch.sub(1, self.phi)), torch.sub(1, torch.square(self.phi))))
            part4_2 = torch.mul(torch.mul(self.beta, self.phi), torch.add(1, torch.sub(torch.mul(2, self.phi), torch.pow(self.phi, h))))
            part4_2 = torch.add(torch.mul(torch.mul(2, self.alpha), torch.sub(1, torch.square(self.phi))), part4_2)
            part4 = torch.mul(part4_1, part4_2)

            fc_var = torch.add(part1, part3)
            fc_var = torch.sub(fc_var, part4)
            fc_var = torch.add(1, fc_var)
            fc_var = torch.mul(self.residual_variance, fc_var)

        return torch.mul(self.c, torch.sqrt(fc_var))
    
    def __smooth_level(self, lprev,bprev): 
        """Calculate the level"""

        self.level = torch.add(torch.add(lprev,torch.mul(bprev, self.phi)), torch.mul(self.alpha, self.error))
    
    def __smooth_error(self, y, y_hat):  
        """Calculate error"""

        self.error = torch.sub(y, y_hat)

    def __smooth_trend(self, bprev):
        """Calculate the level"""

        self.trend = torch.add(torch.mul(bprev, self.phi), torch.mul(self.beta, self.error))

    def fit(self):               
        """
        Fit the model to the data according to the equations:
        y_t = l_{t-1} + phi*b_{t-1} + e_t
        l_t = l_{t-1} + phi*b_{t-1} + alpha*e_t
        b_t = phi*b_{t-1} + beta*e_t
        """

        self.fitted = torch.zeros(size=self.dep_var.shape)

        for index, row in enumerate(self.dep_var):

            if index == 0:
                y_hat = row
                self.level = self.initial_level
                self.trend = self.initial_trend
                self.error = torch.tensor(0, dtype=torch.float32)
                self.fitted[0] = row
            
            else:
                
                y_hat = torch.add(self.level, torch.mul(self.phi,self.trend))
                self.__smooth_error(row, y_hat)
                lprev, bprev = self.level, self.trend
                self.__smooth_level(lprev, bprev)
                self.__smooth_trend(bprev)
                self.fitted[index] = y_hat
            
            self.__update_res_variance(row, y_hat)
    
    def predict(self, h, confidence = None):
        """
        Predict the next h values of the target variable.

        Arguments:

        *h (int): Future time-steps to forecast
        *confidence (Optional[float]): Confidence level to generate prediction intervals; None or values between (0,1)
        """

        if confidence:
            self.__calculate_conf_level(confidence)
            
        self.forecast = torch.tensor([])
        upper_bounds = torch.tensor([])
        lower_bounds = torch.tensor([])

        for i in range(1,h+1):
            
            damp_factor = torch.sum(torch.tensor([torch.pow(self.phi, x+1) for x in range(i+1)]))

            step_forecast = torch.add(self.level, torch.mul(damp_factor, self.trend))

            self.forecast = torch.cat((self.forecast, step_forecast))

            if confidence:
                fc_var = self.__get_confidence_interval(h=i)
                upper_bounds = torch.cat((upper_bounds, torch.add(step_forecast, torch.abs(fc_var))))
                lower_bounds = torch.cat((lower_bounds, torch.sub(step_forecast, torch.abs(fc_var))))

        if confidence:
            return self.forecast, (upper_bounds, lower_bounds)
        else:
            return self.forecast
        
class ETS_ANA(ETS_Model):

    def __init__(self, dep_var, trend=None, seasonal=None, error_type=None, damped=False, seasonal_periods=None, **kwargs):
        """Implementation of ETS method with Additive Errors, No Trend and Additive Seasonality"""
        self.trend = None
        self.damped = False
        self.seasonal = "add"
        self.seasonal_periods = seasonal_periods
        self.error_type = "add"

        super().set_allowed_kwargs(["alpha", "beta", "phi", "gamma"])
        super().__init__(dep_var, trend=self.trend,  seasonal=self.seasonal, error_type=self.error_type, seasonal_periods=self.seasonal_periods, 
                                damped=self.damped, **kwargs)
        
        initialize_params = ["alpha", "gamma"]
        self.__initialize_params(initialize_params)

        self.residuals = torch.tensor([])
    
    def __calculate_conf_level(self, conf):
        """Calculate the multiplier for prediction interval"""

        self.c = super().calculate_conf_level(conf)

    def __initialize_params(self, initialize_params):
        """Initialize model parameters"""

        super().initialize_params(initialize_params)

        self.initial_level = torch.tensor(self.init_components["level"])
        self.initial_seasonals = torch.tensor(self.init_components["seasonal"])

        self.alpha, self.beta, self.gamma, self.phi = torch.tensor(list(self.params.values()), dtype=torch.float32)
    
    def __update_res_variance(self, y, y_hat):
        """Update the residual variance"""

        resid = torch.sub(y, y_hat)

        self.residuals, self.residual_mean, self.residual_variance = super().update_res_variance(self.residuals, resid)
        
    def __get_confidence_interval(self, h):
        """Get the confidence interval of the predictions"""

        k = int((h-1)/self.seasonal_periods)

        fc_var = torch.add(1 ,torch.mul(torch.square(self.alpha),  h-1))
        fc_var = torch.add(fc_var, torch.mul(torch.mul(self.gamma, k), torch.add(torch.mul(2, self.alpha), self.gamma)))
        fc_var = torch.mul(self.residual_variance, fc_var)

        return torch.mul(self.c, fc_var)
    
    def __smooth_level(self,lprev): 
        """Calculate the level"""

        self.level = torch.add(lprev, torch.mul(self.alpha, self.error))
    
    def __smooth_error(self, y, y_hat): 
        """Calculate error"""

        self.error = torch.sub(y, y_hat)

    def __smooth_seasonal(self, seasonal):
        """Calculate the level"""

        seasonal = torch.add(seasonal, torch.mul(self.gamma, self.error))
        self.seasonals = torch.cat((self.seasonals, seasonal))

    def fit(self): 
        """
        Fit the model to the data according to the equations:
        y_t = l_{t-1} + s_{t-m} + e_t
        l_t = l_{t-1} + alpha*e_t
        s_t = s_{t-m} + gamma*e_t
        """

        self.fitted = torch.zeros(size=self.dep_var.shape)

        for index, row in enumerate(self.dep_var):

            if index == 0:
                y_hat = row
                self.level = self.initial_level
                self.error = torch.tensor(0, dtype=torch.float32)
                seasonal = self.initial_seasonals[0]
                self.seasonals = torch.tensor([seasonal])
                self.fitted[0] = row
                
            elif index < self.seasonal_periods:
                
                seasonal = self.initial_seasonals[index]
                y_hat = torch.add(self.level, seasonal)
                lprev = self.level
                self.__smooth_error(row, y_hat)
                self.__smooth_level(lprev)
                self.seasonals = torch.cat((self.seasonals, seasonal))

                self.fitted[index] = y_hat
                
            
            else:

                seasonal = self.seasonals[-self.seasonal_periods]
                y_hat = torch.add(self.level, seasonal)
                lprev = self.level
                self.__smooth_error(row, y_hat)
                self.__smooth_level(lprev)
                self.__smooth_seasonal(seasonal)

                self.fitted[index] = y_hat
            
            self.__update_res_variance(row, y_hat)
    
    def predict(self, h, confidence = None):
        """
        Predict the next h values of the target variable.

        Arguments:

        *h (int): Future time-steps to forecast
        *confidence (Optional[float]): Confidence level to generate prediction intervals; None or values between (0,1)
        """
        
        if confidence:
            self.__calculate_conf_level(confidence)
            
        self.forecast = torch.tensor([])
        upper_bounds = torch.tensor([])
        lower_bounds = torch.tensor([])
        
        len_s = self.seasonals.shape[0]

        for i in range(1,h+1):

            k = int((h-1)/self.seasonal_periods)
            
            step_forecast = torch.add(self.level, self.seasonals[len_s+i-self.seasonal_periods*(k+1)])

            self.forecast = torch.cat((self.forecast, step_forecast))

            if confidence:
                fc_var = self.__get_confidence_interval(h=i)
                upper_bounds = torch.cat((upper_bounds, torch.add(step_forecast, torch.abs(fc_var))))
                lower_bounds = torch.cat((lower_bounds, torch.sub(step_forecast, torch.abs(fc_var))))
                
        if confidence:
            return self.forecast, (upper_bounds, lower_bounds)
        else:
            return self.forecast

class ETS_AAA(ETS_Model):

    def __init__(self, dep_var, trend=None, seasonal=None, error_type=None, damped=False, seasonal_periods=None, **kwargs):
        """Implementation of ETS method with Additive Errors, Additive Trend and Additive Seasonality"""
        self.trend = "add"
        self.damped = damped
        self.seasonal = "add"
        self.seasonal_periods = seasonal_periods
        self.error_type = "add"

        super().set_allowed_kwargs(["alpha", "beta", "phi", "gamma"])
        super().__init__(dep_var, trend=self.trend,  seasonal=self.seasonal, error_type=self.error_type, seasonal_periods=self.seasonal_periods, 
                                damped=self.damped, **kwargs)
        
        initialize_params = ["alpha", "beta", "gamma"]
        if self.damped:
            initialize_params.append("phi")
        self.__initialize_params(initialize_params)

        self.residuals = torch.tensor([])
    
    def __calculate_conf_level(self, conf):
        """Calculate the multiplier for prediction interval"""

        self.c = super().calculate_conf_level(conf)

    def __initialize_params(self, initialize_params):
        """Initialize model parameters"""

        super().initialize_params(initialize_params)

        self.initial_level = torch.tensor(self.init_components["level"])
        self.initial_trend = torch.tensor(self.init_components["trend"])
        self.initial_seasonals = torch.tensor(self.init_components["seasonal"])

        self.alpha, self.beta, self.gamma, self.phi = torch.tensor(list(self.params.values()), dtype=torch.float32)
    
    def __update_res_variance(self, y, y_hat):
        """Update the residual variance"""

        resid = torch.sub(y, y_hat)

        self.residuals, self.residual_mean, self.residual_variance = super().update_res_variance(self.residuals, resid)
        
    def __get_confidence_interval(self, h):
        """Get the confidence interval of the predictions"""

        k = int((h-1)/self.seasonal_periods)
        
        if not self.damped:
            part1 = torch.add(torch.square(self.alpha), torch.mul(torch.mul(self.alpha, self.beta), h))
            part1 = torch.add(part1, torch.divide(torch.mul(torch.square(self.beta), h*(2*h-1)), 6))
            part1 = torch.mul(h-1, part1)

            part2 = torch.add(torch.mul(2,self.alpha), self.gamma)
            part2= torch.add(part2, torch.mul(torch.mul(self.beta, self.seasonal_periods), k+1))
            part2 = torch.mul(part2, torch.mul(self.gamma, k))

            fc_var = torch.add(part1, part2)
            fc_var = torch.add(1, fc_var)
            fc_var = torch.mul(self.residual_variance, fc_var)
        
        if self.damped:

            part1 = torch.mul(torch.square(self.alpha), h-1)
            part2 = torch.mul(torch.mul(self.gamma, k), torch.add(torch.mul(2, self.alpha), self.gamma))

            part3_1 = torch.mul(torch.mul(self.beta, self.phi), h)
            part3_1 = torch.divide(part3_1, torch.square(torch.sub(1, self.phi)))
            part3_2 = torch.add(torch.mul(torch.mul(2, self.alpha), torch.sub(1, self.phi)), torch.mul(self.beta, self.phi))
            part3 = torch.mul(part3_1, part3_2)

            part4_1 =  torch.mul(torch.mul(self.beta, self.phi), torch.sub(1, torch.pow(self.phi, h)))
            part4_1 = torch.divide(part4_1, torch.mul(torch.square(torch.sub(1, self.phi)), torch.sub(1, torch.square(self.phi))))
            part4_2 = torch.mul(torch.mul(self.beta, self.phi), torch.add(1, torch.sub(torch.mul(2, self.phi), torch.pow(self.phi, h))))
            part4_2 = torch.add(torch.mul(torch.mul(2, self.alpha), torch.sub(1, torch.square(self.phi))), part4_2)
            part4 = torch.mul(part4_1, part4_2)

            part5_1 = torch.mul(torch.mul(2, self.beta), torch.mul(self.gamma, self.phi))
            part5_1 = torch.divide(part5_1, torch.mul(torch.sub(1, self.phi), torch.sub(1, torch.pow(self.phi, self.seasonal_periods))))
            part5_2 = torch.mul(torch.pow(self.phi, self.seasonal_periods), torch.sub(1, torch.pow(self.phi, torch.mul(self.seasonal_periods, k))))
            part5_2 = torch.sub(torch.mul(k, torch.sub(1, torch.pow(self.phi, self.seasonal_periods))), part5_2)
            part5 = torch.mul(part5_1, part5_2)

            fc_var = torch.add(part1, part2)
            fc_var = torch.add(fc_var, part3)
            fc_var = torch.sub(fc_var, part4)
            fc_var = torch.add(fc_var, part5)
            fc_var = torch.add(1, fc_var)
            fc_var = torch.mul(self.residual_variance, fc_var)

        return torch.mul(self.c, torch.sqrt(fc_var))
    
    def __smooth_level(self, lprev,bprev):
        """Calculate the level"""

        self.level = torch.add(lprev, torch.mul(self.phi, bprev))
        self.level = torch.add(self.level, torch.mul(self.alpha, self.error))
    
    def __smooth_error(self, y, y_hat):
        """Calculate error"""

        self.error = torch.sub(y, y_hat)

    def __smooth_seasonal(self, seasonal):
        """Calculate seasonal"""
        seasonal = torch.add(seasonal, torch.mul(self.gamma, self.error))
        self.seasonals = torch.cat((self.seasonals, seasonal))

    def __smooth_trend(self, bprev):
        """Calculate trend"""
        self.trend = torch.add(torch.mul(self.error, self.beta), torch.mul(self.phi, bprev))

    def fit(self):

        """
        Fit the model to the data according to the equations:
        y_t = l_{t-1} + phi*b_{t-1} + s_{t-m} + e_t
        l_t = l_{t-1} + phi*b_{t-1} + alpha*e_t
        b_t = phi*b_{t-1} + beta*e_t
        s_t = s_{t-m} + gamma*e_t
        """
        self.fitted = torch.zeros(size=self.dep_var.shape)

        for index, row in enumerate(self.dep_var):
            if index == 0:
                y_hat = row
                self.level = self.initial_level
                self.trend = self.initial_trend
                self.error = torch.tensor(0, dtype=torch.float32)
                seasonal = self.initial_seasonals[0]
                self.seasonals = torch.tensor([seasonal])
                self.fitted[0] = row
            
            elif index < self.seasonal_periods:
                
                seasonal = self.initial_seasonals[index]
                y_hat = torch.add(self.level, torch.add(torch.mul(self.phi, self.trend), seasonal))
                self.__smooth_error(row, y_hat)
                lprev, bprev = self.level, self.trend
                self.__smooth_level(lprev, bprev)
                self.__smooth_trend(bprev)
                self.seasonals = torch.cat((self.seasonals, seasonal))
                self.fitted[index] = y_hat
            else:
                
                seasonal = self.seasonals[-self.seasonal_periods]
                y_hat = torch.add(self.level, torch.add(torch.mul(self.phi, self.trend), seasonal))
                self.__smooth_error(row, y_hat)
                lprev, bprev = self.level, self.trend
                self.__smooth_level(lprev, bprev)
                self.__smooth_trend(bprev)
                self.__smooth_seasonal(seasonal)
                self.fitted[index] =y_hat

            self.__update_res_variance(row, y_hat)

    def predict(self, h, confidence = None):
        """
        Predict the next h values of the target variable.

        Arguments:

        *h (int): Future time-steps to forecast
        *confidence (Optional[float]): Confidence level to generate prediction intervals; None or values between (0,1)
        """

        if confidence:
            self.__calculate_conf_level(confidence)
            
        self.forecast = torch.tensor([])

        upper_bounds = torch.tensor([])
        lower_bounds = torch.tensor([])

        len_s = self.seasonals.shape[0]

        for i in range(1,h+1):
            
            damp_factor = torch.sum(torch.tensor([torch.pow(self.phi, x+1) for x in range(i+1)]))

            k = int((h-1)/self.seasonal_periods)
            

            step_forecast = torch.add(self.level, torch.mul(damp_factor, self.trend))
            step_forecast = torch.add(step_forecast, self.seasonals[len_s+i-self.seasonal_periods*(k+1)])

            self.forecast = torch.cat((self.forecast, step_forecast))

            if confidence:
                fc_var = self.__get_confidence_interval(h=i)
                upper_bounds = torch.cat((upper_bounds, torch.add(step_forecast, torch.abs(fc_var))))
                lower_bounds = torch.cat((lower_bounds, torch.sub(step_forecast, torch.abs(fc_var))))
                

        if confidence:
            return self.forecast, (upper_bounds, lower_bounds)
        else:
            return self.forecast

class ETS_ANM(ETS_Model):

    def __init__(self, dep_var, trend=None, seasonal=None, error_type=None, damped=False, seasonal_periods=None, **kwargs):
        """Implementation of ETS method with Additive Errors, No Trend and Multiplicative Seasonality"""
        self.trend = None
        self.damped = False
        self.seasonal = "mul"
        self.seasonal_periods = seasonal_periods
        self.error_type = "add"

        super().set_allowed_kwargs(["alpha", "beta", "phi", "gamma"])
        super().__init__(dep_var, trend=self.trend,  seasonal=self.seasonal, error_type=self.error_type, seasonal_periods=self.seasonal_periods, 
                                damped=self.damped, **kwargs)
        
        initialize_params = ["alpha", "gamma"]

        self.__initialize_params(initialize_params)

        self.residuals = torch.tensor([])
    
    def __calculate_conf_level(self, conf):
        """Calculate the multiplier for prediction interval"""

        self.c = super().calculate_conf_level(conf)

    def __initialize_params(self, initialize_params):
        """Initialize model parameters"""

        super().initialize_params(initialize_params)

        self.initial_level = torch.tensor(self.init_components["level"])
        self.initial_seasonals = torch.tensor(self.init_components["seasonal"])

        self.alpha, self.beta, self.gamma, self.phi = torch.tensor(list(self.params.values()), dtype=torch.float32)
    
    def __update_res_variance(self, y, y_hat):
        """Update the residual variance"""

        resid = torch.sub(y, y_hat)

        self.residuals, self.residual_mean, self.residual_variance = super().update_res_variance(self.residuals, resid)
        
    def __get_confidence_interval(self, h, conf):
        """Get the confidence interval of the predictions"""
        return super().future_sample_paths(h, conf)

    def __smooth_level(self,lprev, seasonal):
        """Calculate the level"""

        self.level = torch.add(lprev, torch.divide(torch.mul(self.alpha, self.error), seasonal))
    
    def __smooth_error(self, y, y_hat):  
        """Calculate error"""

        self.error = torch.sub(y,y_hat)

    def __smooth_seasonal(self, seasonal, lprev):
        """Calculate the level"""

        seasonal = torch.add(seasonal, torch.divide(torch.mul(self.gamma, self.error),lprev))
        self.seasonals = torch.cat((self.seasonals, seasonal))

    def fit(self):
        """
        Fit the model to the data according to the equations:
        y_t = l_{t-1}*s_{t-m} + e_t
        l_t = l_{t-1} + alpha*e_t/s_{t-m}
        s_t = s_{t-m} + gamma*e_t/l_{t-1}
        """

        self.fitted = torch.zeros(size=self.dep_var.shape)

        for index, row in enumerate(self.dep_var):

            if index == 0:
                y_hat = row
                self.level = self.initial_level
                self.error = torch.tensor(0, dtype=torch.float32)
                seasonal = self.initial_seasonals[0]
                self.seasonals = seasonal
                self.fitted[0] = row
                
            elif index < self.seasonal_periods:
                seasonal = self.initial_seasonals[index]
                y_hat = torch.mul(self.level,seasonal)
                lprev = self.level
                self.__smooth_error(row, y_hat)
                self.__smooth_level(lprev, seasonal)
                self.seasonals = torch.cat((self.seasonals, seasonal))

                self.fitted[index] = y_hat
                
            else:
                seasonal = self.seasonals[-self.seasonal_periods]
                y_hat = torch.mul(self.level,seasonal)
                lprev = self.level
                self.__smooth_error(row, y_hat)
                self.__smooth_level(lprev, seasonal)
                self.__smooth_seasonal(seasonal,lprev)

                self.fitted[index] = y_hat
            
            self.__update_res_variance(row, y_hat)
    
    def predict(self, h, confidence = None):
        """
        Predict the next h values of the target variable.

        Arguments:

        *h (int): Future time-steps to forecast
        *confidence (Optional[float]): Confidence level to generate prediction intervals; None or values between (0,1)
        """

        self.forecast = torch.tensor([])
        
        len_s = self.seasonals.shape[0]

        for i in range(1,h+1):

            k = int((h-1)/self.seasonal_periods)
            
            step_forecast = torch.mul(self.level, self.seasonals[len_s+i-self.seasonal_periods*(k+1)])

            self.forecast = torch.cat((self.forecast, step_forecast))
                
        if confidence:
            bounds = self.__get_confidence_interval(h, conf=confidence)
            upper_bounds = torch.add(self.forecast, bounds)
            lower_bounds = torch.sub(self.forecast, bounds)

            return self.forecast, (upper_bounds, lower_bounds)
        else:
            return self.forecast

class ETS_AAM(ETS_Model):

    def __init__(self, dep_var, trend=None, seasonal=None, error_type=None, damped=False, seasonal_periods=None, **kwargs):

        """Implementation of ETS method with Additive Errors, Additive Trend and Multiplicative Seasonality"""
        self.trend = "add"
        self.damped = damped
        self.seasonal = "mul"
        self.seasonal_periods = seasonal_periods
        self.error_type = "add"

        super().set_allowed_kwargs(["alpha", "beta", "phi", "gamma"])
        super().__init__(dep_var, trend=self.trend,  seasonal=self.seasonal, error_type=self.error_type, seasonal_periods=self.seasonal_periods, 
                                damped=self.damped, **kwargs)
        
        initialize_params = ["alpha", "beta", "gamma"]
        if self.damped:
            initialize_params.append("phi")
        self.__initialize_params(initialize_params)

        self.residuals = torch.tensor([])
    
    def __calculate_conf_level(self, conf):
        """Calculate the multiplier for prediction interval"""

        self.c = super().calculate_conf_level(conf)

    def __initialize_params(self, initialize_params):
        """Initialize model parameters"""

        super().initialize_params(initialize_params)

        self.initial_level = torch.tensor(self.init_components["level"])
        self.initial_trend = torch.tensor(self.init_components["trend"])
        self.initial_seasonals = torch.tensor(self.init_components["seasonal"])

        self.alpha, self.beta, self.gamma, self.phi = torch.tensor(list(self.params.values()), dtype=torch.float32)
    
    def __update_res_variance(self, y, y_hat):
        """Update the residual variance"""

        resid = torch.sub(y, y_hat)

        self.residuals, self.residual_mean, self.residual_variance = super().update_res_variance(self.residuals, resid)
        
    def __get_confidence_interval(self, h, conf):
        """Get the confidence interval of the predictions"""        
        return super().future_sample_paths(h, conf)
    
    def __smooth_level(self, lprev, bprev, seasonal):
        """Calculate the level"""

        self.level = torch.add(torch.mul(self.phi, bprev), torch.divide(torch.mul(self.alpha, self.error), seasonal))
        self.level = torch.add(self.level, lprev)
    
    def __smooth_trend(self, bprev, seasonal):
        """Calculate trend"""
        self.trend = torch.divide(torch.mul(self.beta, self.error), seasonal)
        self.trend = torch.add(self.trend, torch.mul(self.phi, bprev))

    def __smooth_seasonal(self, lprev, bprev):
        """Calculate seasonal"""
        seasonal = torch.divide(torch.mul(self.gamma, self.error), torch.add(lprev, torch.mul(self.phi, bprev)))
        seasonal = torch.add(seasonal, self.seasonals[-self.seasonal_periods])

        self.seasonals = torch.cat((self.seasonals, seasonal))
    
    def __smooth_error(self, y, y_hat):
        """Calculate error"""

        self.error = torch.sub(y, y_hat)

    def fit(self):
        """
        Fit the model to the data according to the equations:
        y_t = (l_{t-1} + phi*b_{t-1})*s_{t-m} + e_t
        l_t = l_{t-1} + phi*b_{t-1} + alpha*e_t/s_{t-m}
        b_t = phi*b_{t-1} + beta*e_t/s_{t-m}
        s_t = s_{t-m} + gamma*e_t/(l_{t-1} + phi*b_{t-1})
        """

        self.fitted = torch.zeros(size=self.dep_var.shape)

        for index, row in enumerate(self.dep_var):

            if index == 0:
                y_hat = row
                self.level = self.initial_level
                self.trend = self.initial_trend
                self.error = torch.tensor(0, dtype=torch.float32)
                seasonal = self.initial_seasonals[0]
                self.seasonals = seasonal
                self.fitted[0] = row
            
            elif index < self.seasonal_periods:
                
                seasonal = self.initial_seasonals[index]
                y_hat = torch.mul(torch.add(self.level, torch.mul(self.phi, self.trend)), seasonal)
                lprev, bprev = self.level, self.trend
                self.__smooth_error(row, y_hat)
                self.__smooth_level(lprev, bprev, seasonal)
                self.__smooth_trend(bprev, seasonal)
                self.seasonals = torch.cat((self.seasonals, seasonal))

                self.fitted[index] = y_hat
                
            
            else:
                
                seasonal = self.seasonals[-self.seasonal_periods]
                y_hat = torch.mul(torch.add(self.level, torch.mul(self.phi, self.trend)), seasonal)
                lprev, bprev = self.level, self.trend
                self.__smooth_error(row, y_hat)
                self.__smooth_level(lprev, bprev, seasonal)
                self.__smooth_trend(bprev, seasonal)
                self.__smooth_seasonal(lprev, bprev)

                self.fitted[index] = y_hat
            
            self.__update_res_variance(row, y_hat)

    
    def predict(self, h, confidence = None):
        """
        Predict the next h values of the target variable.

        Arguments:

        *h (int): Future time-steps to forecast
        *confidence (Optional[float]): Confidence level to generate prediction intervals; None or values between (0,1)
        """

        self.forecast = torch.tensor([])
        
        len_s = self.seasonals.shape[0]

        for i in range(1,h+1):
            
            damp_factor = torch.sum(torch.tensor([torch.pow(self.phi, x+1) for x in range(i+1)]))
            k = int((h-1)/self.seasonal_periods)
            
            step_forecast = torch.mul(self.seasonals[len_s+i-self.seasonal_periods*(k+1)], torch.add(self.level, torch.mul(damp_factor, self.trend)))

            self.forecast = torch.cat((self.forecast, step_forecast))

                
        if confidence:
            bounds = self.__get_confidence_interval(h, conf=confidence)
            upper_bounds = torch.add(self.forecast, bounds)
            lower_bounds = torch.sub(self.forecast, bounds)
            
            return self.forecast, (upper_bounds, lower_bounds)
        else:
            return self.forecast

class ETS_MNN(ETS_Model):

    def __init__(self, dep_var, trend=None, seasonal=None, error_type=None, damped=False, seasonal_periods=None, **kwargs):
        """Implementation of ETS method with Multiplicative Errors, No Trend and No Seasonality"""
        self.trend = None
        self.damped = False
        self.seasonal = None
        self.seasonal_periods = None
        self.error_type = "mul"

        super().set_allowed_kwargs(["alpha", "beta", "phi", "gamma"])
        super().__init__(dep_var, trend=self.trend,  seasonal=self.seasonal, error_type=self.error_type, seasonal_periods=self.seasonal_periods, 
                                damped=self.damped, **kwargs)
        
        initialize_params = ["alpha"]
        self.__initialize_params(initialize_params)

        self.residuals = torch.tensor([])
    
    def __calculate_conf_level(self, conf):
        """Calculate the multiplier for prediction interval"""

        self.c = super().calculate_conf_level(conf)

    def __initialize_params(self, initialize_params):
        """Initialize model parameters"""

        super().initialize_params(initialize_params)

        self.initial_level = torch.tensor(self.init_components["level"])

        self.alpha, self.beta, self.gamma, self.phi = torch.tensor(list(self.params.values()), dtype=torch.float32)
    
    def __update_res_variance(self, y, y_hat):
        """Update the residual variance"""

        resid = torch.sub(y, y_hat)

        self.residuals, self.residual_mean, self.residual_variance = super().update_res_variance(self.residuals, resid)

    def __get_confidence_interval(self, h, conf):
        """Get the confidence interval of the predictions"""        
        return super().future_sample_paths(h, conf)
    
    def __smooth_level(self, lprev):
        """Calculate the level"""

        self.level = torch.mul(lprev, torch.add(1, torch.mul(self.alpha, self.error)))
    
    def __smooth_error(self, y, y_hat):
        """Calculate error"""

        self.error = torch.divide(torch.sub(y, y_hat), y_hat)

    def fit(self):
        """
        Fit the model to the data according to the equations:
        y_t = l_{t-1}*(1 + e_t)
        l_t = l_{t-1}*(1 + alpha*e_t)
        """

        self.fitted = torch.zeros(size=self.dep_var.shape)

        for index, row in enumerate(self.dep_var):

            if index == 0:
                y_hat = row
                self.level = self.initial_level
                self.error = torch.tensor(0, dtype=torch.float32)
                self.fitted[0] = row
            
            else:
                y_hat = self.level
                lprev = self.level
                self.__smooth_error(row, y_hat)
                self.__smooth_level(lprev)
                self.fitted[index] = y_hat
            
            self.__update_res_variance(row, y_hat)
    
    def predict(self, h, confidence = None):
        """
        Predict the next h values of the target variable.

        Arguments:

        *h (int): Future time-steps to forecast
        *confidence (Optional[float]): Confidence level to generate prediction intervals; None or values between (0,1)
        """
            
        self.forecast = torch.tensor([])

        for i in range(1,h+1):
            
            step_forecast = self.level

            self.forecast = torch.cat((self.forecast, step_forecast))

                
        if confidence:
            bounds = self.__get_confidence_interval(h, conf=confidence)
            upper_bounds = torch.add(self.forecast, bounds)
            lower_bounds = torch.sub(self.forecast, bounds)
            
            return self.forecast, (upper_bounds, lower_bounds)
        else:
            return self.forecast

class ETS_MAN(ETS_Model):

    def __init__(self, dep_var, trend=None, seasonal=None, error_type=None, damped=False, seasonal_periods=None, **kwargs):
        """Implementation of ETS method with Multiplicative Errors, Additive Trend and No Seasonality"""
        self.trend = "add"
        self.damped = damped
        self.seasonal = None
        self.seasonal_periods = None
        self.error_type = "mul"

        super().set_allowed_kwargs(["alpha", "beta", "phi", "gamma"])
        super().__init__(dep_var, trend=self.trend,  seasonal=self.seasonal, error_type=self.error_type, seasonal_periods=self.seasonal_periods, 
                                damped=self.damped, **kwargs)
        
        initialize_params = ["alpha", "beta"]
        if self.damped:
            initialize_params.append("phi")
        self.__initialize_params(initialize_params)

        self.residuals = torch.tensor([])
    
    def __calculate_conf_level(self, conf):
        """Calculate the multiplier for prediction interval"""

        self.c = super().calculate_conf_level(conf)

    def __initialize_params(self, initialize_params):
        """Initialize model parameters"""

        super().initialize_params(initialize_params)

        self.initial_level = torch.tensor(self.init_components["level"])
        self.initial_trend = torch.tensor(self.init_components["trend"])

        self.alpha, self.beta, self.gamma, self.phi = torch.tensor(list(self.params.values()), dtype=torch.float32)
    
    def __update_res_variance(self, y, y_hat):
        """Update the residual variance"""

        resid = torch.sub(y, y_hat)

        self.residuals, self.residual_mean, self.residual_variance = super().update_res_variance(self.residuals, resid)
        
    def __get_confidence_interval(self, h, conf): 
        """Get the confidence interval of the predictions"""
        return super().future_sample_paths(h, conf)

    def __smooth_level(self, lprev,bprev): #done
        """Calculate the level"""

        self.level = torch.mul(torch.add(lprev,torch.mul(self.phi, bprev)), torch.add(1, torch.mul(self.alpha, self.error)))
    
    def __smooth_error(self, y, y_hat):  #done
        """Calculate error"""

        self.error = torch.divide(torch.sub(y, y_hat), y_hat)
    
    def __smooth_trend(self, bprev,lprev): ##done
        """Calculate the trend"""

        self.trend = torch.add(torch.mul(self.phi, bprev), torch.mul(self.beta, torch.mul(torch.add(lprev,torch.mul(self.phi, bprev)),self.error)))

    def fit(self):

        """
        Fit the model to the data according to the equations:
        y_t = (l_{t-1} + phi*b_{t-1})*(1 + e_t)
        l_t = (l_{t-1} + phi*b_{t-1})*(1 + alpha*e_t)
        b_t = phi*b_{t-1} + beta*(l_{t-1} + phi*b_{t-1})*e_t
        """

        self.fitted = torch.zeros(size=self.dep_var.shape)

        for index, row in enumerate(self.dep_var):

            if index == 0:
                y_hat = row
                self.level = self.initial_level
                self.trend = self.initial_trend
                self.error = torch.tensor(0, dtype=torch.float32)
                self.fitted[0] = row
            
            else:
                
                y_hat = torch.add(self.level, torch.mul(self.phi,self.trend))
                self.__smooth_error(row, y_hat)
                lprev, bprev = self.level, self.trend
                self.__smooth_level(lprev, bprev)
                self.__smooth_trend(bprev, lprev)
                self.fitted[index] = y_hat
            
            self.__update_res_variance(row, y_hat)
    
    
    def predict(self, h, confidence = None):
        """
        Predict the next h values of the target variable.

        Arguments:

        *h (int): Future time-steps to forecast
        *confidence (Optional[float]): Confidence level to generate prediction intervals; None or values between (0,1)
        """

        self.forecast = torch.tensor([])

        for i in range(1,h+1):
            
            damp_factor = torch.sum(torch.tensor([torch.pow(self.phi, x+1) for x in range(i+1)]))

            step_forecast = torch.add(self.level, torch.mul(damp_factor, self.trend))

            self.forecast = torch.cat((self.forecast, step_forecast))            

        if confidence:
            bounds = self.__get_confidence_interval(h, conf=confidence)
            upper_bounds = torch.add(self.forecast, bounds)
            lower_bounds = torch.sub(self.forecast, bounds)
            
            return self.forecast, (upper_bounds, lower_bounds)
        else:
            return self.forecast

class ETS_MNA(ETS_Model):

    def __init__(self, dep_var, trend=None, seasonal=None, error_type=None, damped=False, seasonal_periods=None, **kwargs):
        """Implementation of ETS method with Multiplicative Errors, No Trend and Additive Seasonality"""
        self.trend = None
        self.damped = False
        self.seasonal = "add"
        self.seasonal_periods = seasonal_periods
        self.error_type = "add"

        super().set_allowed_kwargs(["alpha", "beta", "phi", "gamma"])
        super().__init__(dep_var, trend=self.trend,  seasonal=self.seasonal, error_type=self.error_type, seasonal_periods=self.seasonal_periods, 
                                damped=self.damped, **kwargs)
        
        initialize_params = ["alpha", "gamma"]
        self.__initialize_params(initialize_params)

        self.residuals = torch.tensor([])
    
    def __calculate_conf_level(self, conf):
        """Calculate the multiplier for prediction interval"""

        self.c = super().calculate_conf_level(conf)

    def __initialize_params(self, initialize_params):
        """Initialize model parameters"""

        super().initialize_params(initialize_params)

        self.initial_level = torch.tensor(self.init_components["level"])
        self.initial_seasonals = torch.tensor(self.init_components["seasonal"])

        self.alpha, self.beta, self.gamma, self.phi = torch.tensor(list(self.params.values()), dtype=torch.float32)
    
    def __update_res_variance(self, y, y_hat):
        """Update the residual variance"""

        resid = torch.sub(y, y_hat)

        self.residuals, self.residual_mean, self.residual_variance = super().update_res_variance(self.residuals, resid)
        
    def __get_confidence_interval(self, h, conf): 
        """Get the confidence interval of the predictions"""
        return super().future_sample_paths(h, conf)

    def __smooth_level(self,lprev,seasonal):
        """Calculate the level"""

        self.level = torch.add(lprev, torch.mul(self.alpha, torch.mul(torch.add(lprev,seasonal),self.error)))
    
    def __smooth_error(self, y, y_hat):
        """Calculate error"""

        self.error = torch.divide(torch.sub(y,y_hat),y_hat)

    def __smooth_seasonal(self, lprev,seasonal):
        """Calculate the level"""

        seasonal = torch.add(seasonal, torch.mul(self.gamma, torch.mul(torch.add(lprev,seasonal),self.error)))
        self.seasonals = torch.cat((self.seasonals, seasonal))

    def fit(self): 
        """
        Fit the model to the data according to the equations:
        y_t = (l_{t-1} + s_{t-m})*(1 + e_t)
        l_t = l_{t-1} + alpha*(l_{t-1} + s_{t-m})*e_t
        s_t = s_{t-m} + gamma*(l_{t-1} + s_{t-m})*e_t
        """

        self.fitted = torch.zeros(size=self.dep_var.shape)

        for index, row in enumerate(self.dep_var):

            if index == 0:
                y_hat = row
                self.level = self.initial_level
                self.error = torch.tensor(0, dtype=torch.float32)
                seasonal = self.initial_seasonals[0]
                self.seasonals = torch.tensor([seasonal])
                self.fitted[0] = row
                
            elif index < self.seasonal_periods:
                
                seasonal = self.initial_seasonals[index]
                y_hat = torch.add(self.level, seasonal)
                lprev = self.level
                self.__smooth_error(row, y_hat)
                self.__smooth_level(lprev, seasonal)
                self.seasonals = torch.cat((self.seasonals, seasonal))

                self.fitted[index] = y_hat     
            
            else:

                seasonal = self.seasonals[-self.seasonal_periods]
                y_hat = torch.add(self.level, seasonal)
                lprev = self.level
                self.__smooth_error(row, y_hat)
                self.__smooth_level(lprev, seasonal)
                self.__smooth_seasonal(lprev, seasonal)

                self.fitted[index] = y_hat
            
            self.__update_res_variance(row, y_hat)
    
    def predict(self, h, confidence = None):
        """
        Predict the next h values of the target variable.

        Arguments:

        *h (int): Future time-steps to forecast
        *confidence (Optional[float]): Confidence level to generate prediction intervals; None or values between (0,1)
        """
            
        self.forecast = torch.tensor([])

        len_s = self.seasonals.shape[0]

        for i in range(1,h+1):

            k = int((h-1)/self.seasonal_periods)
            
            step_forecast = torch.add(self.level, self.seasonals[len_s+i-self.seasonal_periods*(k+1)])

            self.forecast = torch.cat((self.forecast, step_forecast))
                
        if confidence:
            bounds = self.__get_confidence_interval(h, conf=confidence)
            upper_bounds = torch.add(self.forecast, bounds)
            lower_bounds = torch.sub(self.forecast, bounds)
            
            return self.forecast, (upper_bounds, lower_bounds)
        else:
            return self.forecast

class ETS_MAA(ETS_Model):
    def __init__(self, dep_var, trend=None, seasonal=None, error_type=None, damped=False, seasonal_periods=None, **kwargs):
        """Implementation of ETS method with Multiplicative Errors, Additive Trend and Additive Seasonality"""
        self.trend = "add"
        self.damped = damped
        self.seasonal = "add"
        self.seasonal_periods = seasonal_periods
        self.error_type = "mul"

        super().set_allowed_kwargs(["alpha", "beta", "phi", "gamma"])
        super().__init__(dep_var, trend=self.trend,  seasonal=self.seasonal, error_type=self.error_type, seasonal_periods=self.seasonal_periods, 
                                damped=self.damped, **kwargs)
        
        initialize_params = ["alpha", "beta", "gamma"]
        if self.damped:
            initialize_params.append("phi")
        self.__initialize_params(initialize_params)

        self.residuals = torch.tensor([])
    
    def __calculate_conf_level(self, conf):
        """Calculate the multiplier for prediction interval"""

        self.c = super().calculate_conf_level(conf)

    def __initialize_params(self, initialize_params):
        """Initialize model parameters"""

        super().initialize_params(initialize_params)

        self.initial_level = torch.tensor(self.init_components["level"])
        self.initial_trend = torch.tensor(self.init_components["trend"])
        self.initial_seasonals = torch.tensor(self.init_components["seasonal"])

        self.alpha, self.beta, self.gamma, self.phi = torch.tensor(list(self.params.values()), dtype=torch.float32)
    
    def __update_res_variance(self, y, y_hat):
        """Update the residual variance"""

        resid = torch.sub(y, y_hat)

        self.residuals, self.residual_mean, self.residual_variance = super().update_res_variance(self.residuals, resid)

    def __get_confidence_interval(self, h, conf):
        """Get the confidence interval of the predictions"""
        return super().future_sample_paths(h, conf)
    
    def __smooth_level(self, lprev,bprev,seasonal):
        """Calculate the level"""

        self.level = torch.mul(self.alpha,torch.add(seasonal,torch.add(lprev,torch.mul(self.phi, bprev))))
        self.level = torch.mul(self.error,self.level)
        self.level = torch.add(self.level,torch.add(lprev,torch.mul(self.phi, bprev)))
    
    def __smooth_error(self, y, y_hat):
        """Calculate error"""

        self.error = torch.divide(torch.sub(y, y_hat), y_hat)

    def __smooth_seasonal(self, seasonal, lprev,bprev):
        seasonal = torch.add(seasonal, torch.mul(self.error,torch.mul(self.gamma,torch.add(seasonal,torch.add(lprev, torch.mul(self.phi,bprev))))))
        self.seasonals = torch.cat((self.seasonals, seasonal))

    def __smooth_trend(self, lprev, bprev,seasonal):
        self.trend = torch.add(torch.mul(self.phi, bprev), torch.mul(self.error,torch.mul(self.beta,torch.add(seasonal,torch.add(lprev,torch.mul(self.phi,bprev))))))

    def fit(self):

        """
        Fit the model to the data according to the equations:
        y_t = (l_{t-1} + phi*b_{t-1} + s_{t-m})*(1 + e_t)
        l_t = l_{t-1} + phi*b_{t-1} + alpha*(l_{t-1} + s_{t-m})*e_t
        b_t = phi*b_{t-1} + beta*(l_{t-1} + phi*b_{t-1} + s_{t-m})*e_t
        s_t = s_{t-m} + gamma*(l_{t-1} + phi*b_{t-1} + s_{t-m})*e_t
        """
        self.fitted = torch.zeros(size=self.dep_var.shape)

        for index, row in enumerate(self.dep_var):
            if index == 0:
                y_hat = row
                self.level = self.initial_level
                self.trend = self.initial_trend
                self.error = torch.tensor(0, dtype=torch.float32)
                seasonal = self.initial_seasonals[0]
                self.seasonals = torch.tensor([seasonal])
                self.fitted[0] = row
            
            elif index < self.seasonal_periods:
                
                seasonal = self.initial_seasonals[index]
                y_hat = torch.add(self.level, torch.add(torch.mul(self.phi, self.trend), seasonal))
                self.__smooth_error(row, y_hat)
                lprev, bprev = self.level, self.trend
                self.__smooth_level(lprev, bprev, seasonal)
                self.__smooth_trend(lprev, bprev, seasonal)
                self.seasonals = torch.cat((self.seasonals, seasonal))
                self.fitted[index] = y_hat
            else:
                
                seasonal = self.seasonals[-self.seasonal_periods]
                y_hat = torch.add(self.level, torch.add(torch.mul(self.phi, self.trend), seasonal))
                self.__smooth_error(row, y_hat)
                lprev, bprev = self.level, self.trend
                self.__smooth_level(lprev, bprev, seasonal)
                self.__smooth_trend(lprev, bprev, seasonal)
                self.__smooth_seasonal(seasonal, lprev, bprev)
                self.fitted[index] =y_hat

            self.__update_res_variance(row, y_hat)

    def predict(self, h, confidence = None):
        """
        Predict the next h values of the target variable.

        Arguments:

        *h (int): Future time-steps to forecast
        *confidence (Optional[float]): Confidence level to generate prediction intervals; None or values between (0,1)
        """

        self.forecast = torch.tensor([])

        len_s = self.seasonals.shape[0]

        for i in range(1,h+1):
            
            damp_factor = torch.sum(torch.tensor([torch.pow(self.phi, x+1) for x in range(i+1)]))
            k = int((h-1)/self.seasonal_periods)

            step_forecast = torch.add(self.level, torch.mul(damp_factor, self.trend))
            step_forecast = torch.add(step_forecast, self.seasonals[len_s+i-self.seasonal_periods*(k+1)])

            self.forecast = torch.cat((self.forecast, step_forecast))

        if confidence:
            bounds = self.__get_confidence_interval(h, conf=confidence)
            upper_bounds = torch.add(self.forecast, bounds)
            lower_bounds = torch.sub(self.forecast, bounds)
            
            return self.forecast, (upper_bounds, lower_bounds)
        else:
            return self.forecast

class ETS_MNM(ETS_Model):

    def __init__(self, dep_var, trend=None, seasonal=None, error_type=None, damped=False, seasonal_periods=None, **kwargs):
        """Implementation of ETS method with Multiplicative Errors, No Trend and Multiplicative Seasonality"""
        self.trend = None
        self.damped = False
        self.seasonal = "mul"
        self.seasonal_periods = seasonal_periods
        self.error_type = "mul"

        super().set_allowed_kwargs(["alpha", "beta", "phi", "gamma"])
        super().__init__(dep_var, trend=self.trend,  seasonal=self.seasonal, error_type=self.error_type, seasonal_periods=self.seasonal_periods, 
                                damped=self.damped, **kwargs)
        
        initialize_params = ["alpha", "gamma"]

        self.__initialize_params(initialize_params)

        self.residuals = torch.tensor([])
    
    def __calculate_conf_level(self, conf):
        """Calculate the multiplier for prediction interval"""

        self.c = super().calculate_conf_level(conf)

    def __initialize_params(self, initialize_params):
        """Initialize model parameters"""

        super().initialize_params(initialize_params)

        self.initial_level = torch.tensor(self.init_components["level"])
        self.initial_seasonals = torch.tensor(self.init_components["seasonal"])

        self.alpha, self.beta, self.gamma, self.phi = torch.tensor(list(self.params.values()), dtype=torch.float32)
    
    def __update_res_variance(self, y, y_hat):
        """Update the residual variance"""

        resid = torch.sub(y, y_hat)

        self.residuals, self.residual_mean, self.residual_variance = super().update_res_variance(self.residuals, resid)
        
    def __get_confidence_interval(self, h, conf):
        """Get the confidence interval of the predictions"""
        return super().future_sample_paths(h, conf)

    def __smooth_level(self, lprev, seasonal):
        """Calculate the level"""

        self.level = torch.mul(lprev, torch.add(1, torch.mul(self.alpha, self.error)))

    def __smooth_error(self, y, y_hat):  
        """Calculate error"""
        self.error = torch.divide(torch.sub(y, y_hat), y_hat)

    def __smooth_seasonal(self, seasonal, lprev):
        """Calculate the level"""

        seasonal = torch.mul(seasonal, torch.add(1, torch.mul(self.gamma, self.error)))
        self.seasonals = torch.cat((self.seasonals, seasonal))

    def fit(self):
        """
        Fit the model to the data according to the equations:
        y_t = l_{t-1}*s_{t-m}*(1 + e_t)
        l_t = l_{t-1}*(1 + alpha*e_t)
        s_t = s_{t-m}*(1 + gamma*e_t)
        """

        self.fitted = torch.zeros(size=self.dep_var.shape)

        for index, row in enumerate(self.dep_var):

            if index == 0:
                y_hat = row
                self.level = self.initial_level
                self.error = torch.tensor(0, dtype=torch.float32)
                seasonal = self.initial_seasonals[0]
                self.seasonals = seasonal
                self.fitted[0] = row
                
            elif index < self.seasonal_periods:
                seasonal = self.initial_seasonals[index]
                y_hat = torch.mul(self.level,seasonal)
                lprev = self.level
                self.__smooth_error(row, y_hat)
                self.__smooth_level(lprev, seasonal)
                self.seasonals = torch.cat((self.seasonals, seasonal))

                self.fitted[index] = y_hat
                       
            else:
                seasonal = self.seasonals[-self.seasonal_periods]
                y_hat = torch.mul(self.level,seasonal)
                lprev = self.level
                self.__smooth_error(row, y_hat)
                self.__smooth_level(lprev, seasonal)
                self.__smooth_seasonal(seasonal,lprev)

                self.fitted[index] = y_hat
            
            self.__update_res_variance(row, y_hat)
    
    def predict(self, h, confidence = None):
        """
        Predict the next h values of the target variable.

        Arguments:

        *h (int): Future time-steps to forecast
        *confidence (Optional[float]): Confidence level to generate prediction intervals; None or values between (0,1)
        """
            
        self.forecast = torch.tensor([])
        
        len_s = self.seasonals.shape[0]

        for i in range(1,h+1):

            k = int((h-1)/self.seasonal_periods)
            
            step_forecast = torch.mul(self.level, self.seasonals[len_s+i-self.seasonal_periods*(k+1)])

            self.forecast = torch.cat((self.forecast, step_forecast))
                
        if confidence:
            bounds = self.__get_confidence_interval(h, conf=confidence)
            upper_bounds = torch.add(self.forecast, bounds)
            lower_bounds = torch.sub(self.forecast, bounds)
            
            return self.forecast, (upper_bounds, lower_bounds)
        else:
            return self.forecast

class ETS_MAM(ETS_Model):

    def __init__(self, dep_var, trend=None, seasonal=None, error_type=None, damped=False, seasonal_periods=None, **kwargs):
        """Implementation of ETS method with Multiplicative Errors, Additive Trend and Multiplicative Seasonality"""
        self.trend = "add"
        self.damped = damped
        self.seasonal = "mul"
        self.seasonal_periods = seasonal_periods
        self.error_type = "mul"

        super().set_allowed_kwargs(["alpha", "beta", "phi", "gamma"])
        super().__init__(dep_var, trend=self.trend,  seasonal=self.seasonal, error_type=self.error_type, seasonal_periods=self.seasonal_periods, 
                                damped=self.damped, **kwargs)
        
        initialize_params = ["alpha", "beta", "gamma"]
        if self.damped:
            initialize_params.append("phi")
        self.__initialize_params(initialize_params)

        self.residuals = torch.tensor([])
    
    def __calculate_conf_level(self, conf):
        """Calculate the multiplier for prediction interval"""

        self.c = super().calculate_conf_level(conf)

    def __initialize_params(self, initialize_params):
        """Initialize model parameters"""

        super().initialize_params(initialize_params)

        self.initial_level = torch.tensor(self.init_components["level"])
        self.initial_trend = torch.tensor(self.init_components["trend"])
        self.initial_seasonals = torch.tensor(self.init_components["seasonal"])

        self.alpha, self.beta, self.gamma, self.phi = torch.tensor(list(self.params.values()), dtype=torch.float32)
    
    def __update_res_variance(self, y, y_hat):
        """Update the residual variance"""

        resid = torch.sub(y, y_hat)

        self.residuals, self.residual_mean, self.residual_variance = super().update_res_variance(self.residuals, resid)
        
    def __get_confidence_interval(self, h, conf):
        """Get the confidence interval of the predictions"""        
        return super().future_sample_paths(h, conf)
    
    def __smooth_level(self, lprev, bprev):
        """Calculate the level"""

        self.level = torch.add(lprev, torch.mul(self.phi, bprev))
        self.level = torch.mul(self.level, torch.add(1, torch.mul(self.alpha, self.error)))
    
    def __smooth_trend(self, lprev, bprev):
        """Calculate trend"""
        self.trend = torch.mul(torch.mul(self.error, self.beta), torch.add(lprev , torch.mul(self.phi, bprev)))
        self.trend = torch.add(self.trend, torch.mul(self.phi, bprev))

    def __smooth_seasonal(self):
        """Calculate seasonal"""
        seasonal = torch.mul(self.seasonals[-self.seasonal_periods], torch.add(1, torch.mul(self.gamma, self.error)))
        self.seasonals = torch.cat((self.seasonals, seasonal))
    
    def __smooth_error(self, y, y_hat):
        """Calculate error"""
        self.error = torch.divide(torch.sub(y, y_hat), y_hat)

    def fit(self):
        """
        Fit the model to the data according to the equations:
        y_t = (l_{t-1} + phi*b_{t-1})*s_{t-m}*(1 + e_t)
        l_t = (l_{t-1} + phi*b_{t-1})*(1 + alpha*e_t)
        b_t = phi*b_{t-1} + beta*(l_{t-1} + phi*b_{t-1})*e_t
        s_t = s_{t-m}*(1 + gamma*e_t)
        """

        self.fitted = torch.zeros(size=self.dep_var.shape)

        for index, row in enumerate(self.dep_var):

            if index == 0:
                y_hat = row
                self.level = self.initial_level
                self.trend = self.initial_trend
                self.error = torch.tensor(0, dtype=torch.float32)
                seasonal = self.initial_seasonals[0]
                self.seasonals = seasonal
                self.fitted[0] = row
            
            elif index < self.seasonal_periods:
                seasonal = self.initial_seasonals[index]
                y_hat = torch.mul(torch.add(self.level, torch.mul(self.phi, self.trend)), seasonal)
                lprev, bprev = self.level, self.trend
                self.__smooth_error(row, y_hat)
                self.__smooth_level(lprev, bprev)
                self.__smooth_trend(lprev, bprev)
                self.seasonals = torch.cat((self.seasonals, seasonal))

                self.fitted[index] = y_hat
            
            else:
                seasonal = self.seasonals[-self.seasonal_periods]
                y_hat = torch.mul(torch.add(self.level, torch.mul(self.phi, self.trend)), seasonal)
                lprev, bprev = self.level, self.trend
                self.__smooth_error(row, y_hat)
                self.__smooth_level(lprev, bprev)
                self.__smooth_trend(lprev, bprev)
                self.__smooth_seasonal()

                self.fitted[index] = y_hat
            
            self.__update_res_variance(row, y_hat)

    
    def predict(self, h, confidence = None):
        """
        Predict the next h values of the target variable.

        Arguments:

        *h (int): Future time-steps to forecast
        *confidence (Optional[float]): Confidence level to generate prediction intervals; None or values between (0,1)
        """

        self.forecast = torch.tensor([])
        
        len_s = self.seasonals.shape[0]

        for i in range(1,h+1):
            
            damp_factor = torch.sum(torch.tensor([torch.pow(self.phi, x+1) for x in range(i+1)]))
            k = int((h-1)/self.seasonal_periods)
            
            step_forecast = torch.mul(self.seasonals[len_s+i-self.seasonal_periods*(k+1)], torch.add(self.level, torch.mul(damp_factor, self.trend)))

            self.forecast = torch.cat((self.forecast, step_forecast))

        if confidence:
            bounds = self.__get_confidence_interval(h, conf=confidence)
            upper_bounds = torch.add(self.forecast, bounds)
            lower_bounds = torch.sub(self.forecast, bounds)
            
            return self.forecast, (upper_bounds, lower_bounds)
        else:
            return self.forecast