1"""
2Linear exponential smoothing models
3
4Author: Chad Fulton
5License: BSD-3
6"""
7
8import numpy as np
9import pandas as pd
10from statsmodels.base.data import PandasData
11
12from statsmodels.genmod.generalized_linear_model import GLM
13from statsmodels.tools.validation import (array_like, bool_like, float_like,
14                                          string_like, int_like)
15
16from statsmodels.tsa.exponential_smoothing import initialization as es_init
17from statsmodels.tsa.statespace import initialization as ss_init
18from statsmodels.tsa.statespace.kalman_filter import (
19    MEMORY_CONSERVE, MEMORY_NO_FORECAST)
20
21from statsmodels.compat.pandas import Appender
22import statsmodels.base.wrapper as wrap
23
24from statsmodels.iolib.summary import forg
25from statsmodels.iolib.table import SimpleTable
26from statsmodels.iolib.tableformatting import fmt_params
27
28from .mlemodel import MLEModel, MLEResults, MLEResultsWrapper
29
30
31class ExponentialSmoothing(MLEModel):
32    """
33    Linear exponential smoothing models
34
35    Parameters
36    ----------
37    endog : array_like
38        The observed time-series process :math:`y`
39    trend : bool, optional
40        Whether or not to include a trend component. Default is False.
41    damped_trend : bool, optional
42        Whether or not an included trend component is damped. Default is False.
43    seasonal : int, optional
44        The number of periods in a complete seasonal cycle for seasonal
45        (Holt-Winters) models. For example, 4 for quarterly data with an
46        annual cycle or 7 for daily data with a weekly cycle. Default is
47        no seasonal effects.
48    initialization_method : str, optional
49        Method for initialize the recursions. One of:
50
51        * 'estimated'
52        * 'concentrated'
53        * 'heuristic'
54        * 'known'
55
56        If 'known' initialization is used, then `initial_level` must be
57        passed, as well as `initial_slope` and `initial_seasonal` if
58        applicable. Default is 'estimated'.
59    initial_level : float, optional
60        The initial level component. Only used if initialization is 'known'.
61    initial_trend : float, optional
62        The initial trend component. Only used if initialization is 'known'.
63    initial_seasonal : array_like, optional
64        The initial seasonal component. An array of length `seasonal`
65        or length `seasonal - 1` (in which case the last initial value
66        is computed to make the average effect zero). Only used if
67        initialization is 'known'.
68    bounds : iterable[tuple], optional
69        An iterable containing bounds for the parameters. Must contain four
70        elements, where each element is a tuple of the form (lower, upper).
71        Default is (0.0001, 0.9999) for the level, trend, and seasonal
72        smoothing parameters and (0.8, 0.98) for the trend damping parameter.
73    concentrate_scale : bool, optional
74        Whether or not to concentrate the scale (variance of the error term)
75        out of the likelihood.
76
77    Notes
78    -----
79    The parameters and states of this model are estimated by setting up the
80    exponential smoothing equations as a special case of a linear Gaussian
81    state space model and applying the Kalman filter. As such, it has slightly
82    worse performance than the dedicated exponential smoothing model,
83    :class:`statsmodels.tsa.holtwinters.ExponentialSmoothing`, and it does not
84    support multiplicative (nonlinear) exponential smoothing models.
85
86    However, as a subclass of the state space models, this model class shares
87    a consistent set of functionality with those models, which can make it
88    easier to work with. In addition, it supports computing confidence
89    intervals for forecasts and it supports concentrating the initial
90    state out of the likelihood function.
91
92    References
93    ----------
94    [1] Hyndman, Rob, Anne B. Koehler, J. Keith Ord, and Ralph D. Snyder.
95        Forecasting with exponential smoothing: the state space approach.
96        Springer Science & Business Media, 2008.
97    """
98    def __init__(self, endog, trend=False, damped_trend=False, seasonal=None,
99                 initialization_method='estimated', initial_level=None,
100                 initial_trend=None, initial_seasonal=None, bounds=None,
101                 concentrate_scale=True, dates=None, freq=None,
102                 missing='none'):
103        # Model definition
104        self.trend = bool_like(trend, 'trend')
105        self.damped_trend = bool_like(damped_trend, 'damped_trend')
106        self.seasonal_periods = int_like(seasonal, 'seasonal', optional=True)
107        self.seasonal = self.seasonal_periods is not None
108        self.initialization_method = string_like(
109            initialization_method, 'initialization_method').lower()
110        self.concentrate_scale = bool_like(concentrate_scale,
111                                           'concentrate_scale')
112
113        # TODO: add validation for bounds (e.g. have all bounds, upper > lower)
114        # TODO: add `bounds_method` argument to choose between "usual" and
115        # "admissible" as in Hyndman et al. (2008)
116        self.bounds = bounds
117        if self.bounds is None:
118            self.bounds = [(1e-4, 1-1e-4)] * 3 + [(0.8, 0.98)]
119
120        # Validation
121        if self.seasonal_periods == 1:
122            raise ValueError('Cannot have a seasonal period of 1.')
123
124        if self.seasonal and self.seasonal_periods is None:
125            raise NotImplementedError('Unable to detect season automatically;'
126                                      ' please specify `seasonal_periods`.')
127
128        if self.initialization_method not in ['concentrated', 'estimated',
129                                              'simple', 'heuristic', 'known']:
130            raise ValueError('Invalid initialization method "%s".'
131                             % initialization_method)
132
133        if self.initialization_method == 'known':
134            if initial_level is None:
135                raise ValueError('`initial_level` argument must be provided'
136                                 ' when initialization method is set to'
137                                 ' "known".')
138            if initial_trend is None and self.trend:
139                raise ValueError('`initial_trend` argument must be provided'
140                                 ' for models with a trend component when'
141                                 ' initialization method is set to "known".')
142            if initial_seasonal is None and self.seasonal:
143                raise ValueError('`initial_seasonal` argument must be provided'
144                                 ' for models with a seasonal component when'
145                                 ' initialization method is set to "known".')
146
147        # Initialize the state space model
148        if not self.seasonal or self.seasonal_periods is None:
149            self._seasonal_periods = 0
150        else:
151            self._seasonal_periods = self.seasonal_periods
152
153        k_states = 2 + int(self.trend) + self._seasonal_periods
154        k_posdef = 1
155
156        init = ss_init.Initialization(k_states, 'known',
157                                      constant=[0] * k_states)
158        super(ExponentialSmoothing, self).__init__(
159            endog, k_states=k_states, k_posdef=k_posdef,
160            initialization=init, dates=dates, freq=freq, missing=missing)
161
162        # Concentrate the scale out of the likelihood function
163        if self.concentrate_scale:
164            self.ssm.filter_concentrated = True
165
166        # Setup fixed elements of the system matrices
167        # Observation error
168        self.ssm['design', 0, 0] = 1.
169        self.ssm['selection', 0, 0] = 1.
170        self.ssm['state_cov', 0, 0] = 1.
171
172        # Level
173        self.ssm['design', 0, 1] = 1.
174        self.ssm['transition', 1, 1] = 1.
175
176        # Trend
177        if self.trend:
178            self.ssm['transition', 1:3, 2] = 1.
179
180        # Seasonal
181        if self.seasonal:
182            k = 2 + int(self.trend)
183            self.ssm['design', 0, k] = 1.
184            self.ssm['transition', k, -1] = 1.
185            self.ssm['transition', k + 1:k_states, k:k_states - 1] = (
186                np.eye(self.seasonal_periods - 1))
187
188        # Initialization of the states
189        if self.initialization_method != 'known':
190            msg = ('Cannot give `%%s` argument when initialization is "%s"'
191                   % initialization_method)
192            if initial_level is not None:
193                raise ValueError(msg % 'initial_level')
194            if initial_trend is not None:
195                raise ValueError(msg % 'initial_trend')
196            if initial_seasonal is not None:
197                raise ValueError(msg % 'initial_seasonal')
198
199        if self.initialization_method == 'simple':
200            initial_level, initial_trend, initial_seasonal = (
201                es_init._initialization_simple(
202                    self.endog[:, 0], trend='add' if self.trend else None,
203                    seasonal='add' if self.seasonal else None,
204                    seasonal_periods=self.seasonal_periods))
205        elif self.initialization_method == 'heuristic':
206            initial_level, initial_trend, initial_seasonal = (
207                es_init._initialization_heuristic(
208                    self.endog[:, 0], trend='add' if self.trend else None,
209                    seasonal='add' if self.seasonal else None,
210                    seasonal_periods=self.seasonal_periods))
211        elif self.initialization_method == 'known':
212            initial_level = float_like(initial_level, 'initial_level')
213            if self.trend:
214                initial_trend = float_like(initial_trend, 'initial_trend')
215            if self.seasonal:
216                initial_seasonal = array_like(initial_seasonal,
217                                              'initial_seasonal')
218
219                if len(initial_seasonal) == self.seasonal_periods - 1:
220                    initial_seasonal = np.r_[initial_seasonal,
221                                             0 - np.sum(initial_seasonal)]
222
223                if len(initial_seasonal) != self.seasonal_periods:
224                    raise ValueError(
225                        'Invalid length of initial seasonal values. Must be'
226                        ' one of s or s-1, where s is the number of seasonal'
227                        ' periods.')
228
229        self._initial_level = initial_level
230        self._initial_trend = initial_trend
231        self._initial_seasonal = initial_seasonal
232        self._initial_state = None
233
234        # Initialize now if possible (if we have a damped trend, then
235        # initialization will depend on the phi parameter, and so has to be
236        # done at each `update`)
237        methods = ['simple', 'heuristic', 'known']
238        if not self.damped_trend and self.initialization_method in methods:
239            self._initialize_constant_statespace(initial_level, initial_trend,
240                                                 initial_seasonal)
241
242        # Save keys for kwarg initialization
243        self._init_keys += ['trend', 'damped_trend', 'seasonal',
244                            'initialization_method', 'initial_level',
245                            'initial_trend', 'initial_seasonal', 'bounds',
246                            'concentrate_scale', 'dates', 'freq', 'missing']
247
248    def _get_init_kwds(self):
249        kwds = super()._get_init_kwds()
250        kwds['seasonal'] = self.seasonal_periods
251        return kwds
252
253    @property
254    def _res_classes(self):
255        return {'fit': (ExponentialSmoothingResults,
256                        ExponentialSmoothingResultsWrapper)}
257
258    def clone(self, endog, exog=None, **kwargs):
259        if exog is not None:
260            raise NotImplementedError(
261                'ExponentialSmoothing does not support `exog`.')
262        return self._clone_from_init_kwds(endog, **kwargs)
263
264    @property
265    def state_names(self):
266        state_names = ['error', 'level']
267        if self.trend:
268            state_names += ['trend']
269        if self.seasonal:
270            state_names += ['seasonal.%d' % i
271                            for i in range(self.seasonal_periods)]
272
273        return state_names
274
275    @property
276    def param_names(self):
277        param_names = ['smoothing_level']
278        if self.trend:
279            param_names += ['smoothing_trend']
280        if self.seasonal:
281            param_names += ['smoothing_seasonal']
282        if self.damped_trend:
283            param_names += ['damping_trend']
284        if not self.concentrate_scale:
285            param_names += ['sigma2']
286
287        # Initialization
288        if self.initialization_method == 'estimated':
289            param_names += ['initial_level']
290            if self.trend:
291                param_names += ['initial_trend']
292            if self.seasonal:
293                param_names += ['initial_seasonal.%d' % i
294                                for i in range(self.seasonal_periods - 1)]
295
296        return param_names
297
298    @property
299    def start_params(self):
300        # Make sure starting parameters aren't beyond or right on the bounds
301        bounds = [(x[0] + 1e-3, x[1] - 1e-3) for x in self.bounds]
302
303        # See Hyndman p.24
304        start_params = [np.clip(0.1, *bounds[0])]
305        if self.trend:
306            start_params += [np.clip(0.01, *bounds[1])]
307        if self.seasonal:
308            start_params += [np.clip(0.01, *bounds[2])]
309        if self.damped_trend:
310            start_params += [np.clip(0.98, *bounds[3])]
311        if not self.concentrate_scale:
312            start_params += [np.var(self.endog)]
313
314        # Initialization
315        if self.initialization_method == 'estimated':
316            initial_level, initial_trend, initial_seasonal = (
317                es_init._initialization_simple(
318                    self.endog[:, 0],
319                    trend='add' if self.trend else None,
320                    seasonal='add' if self.seasonal else None,
321                    seasonal_periods=self.seasonal_periods))
322            start_params += [initial_level]
323            if self.trend:
324                start_params += [initial_trend]
325            if self.seasonal:
326                start_params += initial_seasonal.tolist()[:-1]
327
328        return np.array(start_params)
329
330    @property
331    def k_params(self):
332        k_params = (
333            1 + int(self.trend) + int(self.seasonal) +
334            int(not self.concentrate_scale) + int(self.damped_trend))
335        if self.initialization_method == 'estimated':
336            k_params += (
337                1 + int(self.trend) +
338                int(self.seasonal) * (self._seasonal_periods - 1))
339        return k_params
340
341    def transform_params(self, unconstrained):
342        unconstrained = np.array(unconstrained, ndmin=1)
343        constrained = np.zeros_like(unconstrained)
344
345        # Alpha in (0, 1)
346        low, high = self.bounds[0]
347        constrained[0] = (
348            1 / (1 + np.exp(-unconstrained[0])) * (high - low) + low)
349        i = 1
350
351        # Beta in (0, alpha)
352        if self.trend:
353            low, high = self.bounds[1]
354            high = min(high, constrained[0])
355            constrained[i] = (
356                1 / (1 + np.exp(-unconstrained[i])) * (high - low) + low)
357            i += 1
358
359        # Gamma in (0, 1 - alpha)
360        if self.seasonal:
361            low, high = self.bounds[2]
362            high = min(high, 1 - constrained[0])
363            constrained[i] = (
364                1 / (1 + np.exp(-unconstrained[i])) * (high - low) + low)
365            i += 1
366
367        # Phi in bounds (e.g. default is [0.8, 0.98])
368        if self.damped_trend:
369            low, high = self.bounds[3]
370            constrained[i] = (
371                1 / (1 + np.exp(-unconstrained[i])) * (high - low) + low)
372            i += 1
373
374        # sigma^2 positive
375        if not self.concentrate_scale:
376            constrained[i] = unconstrained[i]**2
377            i += 1
378
379        # Initial parameters are as-is
380        if self.initialization_method == 'estimated':
381            constrained[i:] = unconstrained[i:]
382
383        return constrained
384
385    def untransform_params(self, constrained):
386        constrained = np.array(constrained, ndmin=1)
387        unconstrained = np.zeros_like(constrained)
388
389        # Alpha in (0, 1)
390        low, high = self.bounds[0]
391        tmp = (constrained[0] - low) / (high - low)
392        unconstrained[0] = np.log(tmp / (1 - tmp))
393        i = 1
394
395        # Beta in (0, alpha)
396        if self.trend:
397            low, high = self.bounds[1]
398            high = min(high, constrained[0])
399            tmp = (constrained[i] - low) / (high - low)
400            unconstrained[i] = np.log(tmp / (1 - tmp))
401            i += 1
402
403        # Gamma in (0, 1 - alpha)
404        if self.seasonal:
405            low, high = self.bounds[2]
406            high = min(high, 1 - constrained[0])
407            tmp = (constrained[i] - low) / (high - low)
408            unconstrained[i] = np.log(tmp / (1 - tmp))
409            i += 1
410
411        # Phi in bounds (e.g. default is [0.8, 0.98])
412        if self.damped_trend:
413            low, high = self.bounds[3]
414            tmp = (constrained[i] - low) / (high - low)
415            unconstrained[i] = np.log(tmp / (1 - tmp))
416            i += 1
417
418        # sigma^2 positive
419        if not self.concentrate_scale:
420            unconstrained[i] = constrained[i]**0.5
421            i += 1
422
423        # Initial parameters are as-is
424        if self.initialization_method == 'estimated':
425            unconstrained[i:] = constrained[i:]
426
427        return unconstrained
428
429    def _initialize_constant_statespace(self, initial_level,
430                                        initial_trend=None,
431                                        initial_seasonal=None):
432        # Note: this should be run after `update` has already put any new
433        # parameters into the transition matrix, since it uses the transition
434        # matrix explicitly.
435
436        # Due to timing differences, the state space representation integrates
437        # the trend into the level in the "predicted_state" (only the
438        # "filtered_state" corresponds to the timing of the exponential
439        # smoothing models)
440
441        # Initial values are interpreted as "filtered" values
442        constant = np.array([0., initial_level])
443        if self.trend and initial_trend is not None:
444            constant = np.r_[constant, initial_trend]
445        if self.seasonal and initial_seasonal is not None:
446            constant = np.r_[constant, initial_seasonal]
447        self._initial_state = constant[1:]
448
449        # Apply the prediction step to get to what we need for our Kalman
450        # filter implementation
451        constant = np.dot(self.ssm['transition'], constant)
452
453        self.initialization.constant = constant
454
455    def _initialize_stationary_cov_statespace(self):
456        R = self.ssm['selection']
457        Q = self.ssm['state_cov']
458        self.initialization.stationary_cov = R.dot(Q).dot(R.T)
459
460    def update(self, params, transformed=True, includes_fixed=False,
461               complex_step=False):
462        params = self.handle_params(params, transformed=transformed,
463                                    includes_fixed=includes_fixed)
464
465        # State space system matrices
466        self.ssm['selection', 0, 0] = 1 - params[0]
467        self.ssm['selection', 1, 0] = params[0]
468        i = 1
469        if self.trend:
470            self.ssm['selection', 2, 0] = params[i]
471            i += 1
472        if self.seasonal:
473            self.ssm['selection', 0, 0] -= params[i]
474            self.ssm['selection', i + 1, 0] = params[i]
475            i += 1
476        if self.damped_trend:
477            self.ssm['transition', 1:3, 2] = params[i]
478            i += 1
479        if not self.concentrate_scale:
480            self.ssm['state_cov', 0, 0] = params[i]
481            i += 1
482
483        # State initialization
484        if self.initialization_method == 'estimated':
485            initial_level = params[i]
486            i += 1
487            initial_trend = None
488            initial_seasonal = None
489
490            if self.trend:
491                initial_trend = params[i]
492                i += 1
493            if self.seasonal:
494                initial_seasonal = params[i: i + self.seasonal_periods - 1]
495                initial_seasonal = np.r_[initial_seasonal,
496                                         0 - np.sum(initial_seasonal)]
497            self._initialize_constant_statespace(initial_level, initial_trend,
498                                                 initial_seasonal)
499
500        methods = ['simple', 'heuristic', 'known']
501        if self.damped_trend and self.initialization_method in methods:
502            self._initialize_constant_statespace(
503                self._initial_level, self._initial_trend,
504                self._initial_seasonal)
505
506        self._initialize_stationary_cov_statespace()
507
508    def _compute_concentrated_states(self, params, *args, **kwargs):
509        # Apply the usual filter, but keep forecasts
510        kwargs['conserve_memory'] = MEMORY_CONSERVE & ~MEMORY_NO_FORECAST
511        super().loglike(params, *args, **kwargs)
512
513        # Compute the initial state vector
514        y_tilde = np.array(self.ssm._kalman_filter.forecast_error[0],
515                           copy=True)
516
517        # Need to modify our state space system matrices slightly to get them
518        # back into the form of the innovations framework of
519        # De Livera et al. (2011)
520        T = self['transition', 1:, 1:]
521        R = self['selection', 1:]
522        Z = self['design', :, 1:].copy()
523        i = 1
524        if self.trend:
525            Z[0, i] = 1.
526            i += 1
527        if self.seasonal:
528            Z[0, i] = 0.
529            Z[0, -1] = 1.
530
531        # Now compute the regression components as described in
532        # De Livera et al. (2011), equation (10).
533        D = T - R.dot(Z)
534        w = np.zeros((self.nobs, self.k_states - 1), dtype=D.dtype)
535        w[0] = Z
536        for i in range(self.nobs - 1):
537            w[i + 1] = w[i].dot(D)
538        mod_ols = GLM(y_tilde, w)
539
540        # If we have seasonal parameters, constrain them to sum to zero
541        # (otherwise the initial level gets confounded with the sum of the
542        # seasonals).
543        if self.seasonal:
544            R = np.zeros_like(Z)
545            R[0, -self.seasonal_periods:] = 1.
546            q = np.zeros((1, 1))
547            res_ols = mod_ols.fit_constrained((R, q))
548        else:
549            res_ols = mod_ols.fit()
550
551        # Separate into individual components
552        initial_level = res_ols.params[0]
553        initial_trend = res_ols.params[1] if self.trend else None
554        initial_seasonal = (
555            res_ols.params[-self.seasonal_periods:] if self.seasonal else None)
556
557        return initial_level, initial_trend, initial_seasonal
558
559    @Appender(MLEModel.loglike.__doc__)
560    def loglike(self, params, *args, **kwargs):
561        if self.initialization_method == 'concentrated':
562            self._initialize_constant_statespace(
563                *self._compute_concentrated_states(params, *args, **kwargs))
564            llf = self.ssm.loglike()
565            self.ssm.initialization.constant = np.zeros(self.k_states)
566        else:
567            llf = super().loglike(params, *args, **kwargs)
568        return llf
569
570    @Appender(MLEModel.filter.__doc__)
571    def filter(self, params, cov_type=None, cov_kwds=None,
572               return_ssm=False, results_class=None,
573               results_wrapper_class=None, *args, **kwargs):
574        if self.initialization_method == 'concentrated':
575            self._initialize_constant_statespace(
576                *self._compute_concentrated_states(params, *args, **kwargs))
577
578        results = super().filter(
579            params, cov_type=cov_type, cov_kwds=cov_kwds,
580            return_ssm=return_ssm, results_class=results_class,
581            results_wrapper_class=results_wrapper_class, *args, **kwargs)
582
583        if self.initialization_method == 'concentrated':
584            self.ssm.initialization.constant = np.zeros(self.k_states)
585        return results
586
587    @Appender(MLEModel.smooth.__doc__)
588    def smooth(self, params, cov_type=None, cov_kwds=None,
589               return_ssm=False, results_class=None,
590               results_wrapper_class=None, *args, **kwargs):
591        if self.initialization_method == 'concentrated':
592            self._initialize_constant_statespace(
593                *self._compute_concentrated_states(params, *args, **kwargs))
594
595        results = super().smooth(
596            params, cov_type=cov_type, cov_kwds=cov_kwds,
597            return_ssm=return_ssm, results_class=results_class,
598            results_wrapper_class=results_wrapper_class, *args, **kwargs)
599
600        if self.initialization_method == 'concentrated':
601            self.ssm.initialization.constant = np.zeros(self.k_states)
602        return results
603
604
605class ExponentialSmoothingResults(MLEResults):
606    """
607    Results from fitting a linear exponential smoothing model
608    """
609    def __init__(self, model, params, filter_results, cov_type=None,
610                 **kwargs):
611        super().__init__(model, params, filter_results, cov_type, **kwargs)
612
613        # Save the states
614        self.initial_state = model._initial_state
615        if isinstance(self.data, PandasData):
616            index = self.data.row_labels
617            self.initial_state = pd.DataFrame(
618                [model._initial_state], columns=model.state_names[1:])
619            if model._index_dates and model._index_freq is not None:
620                self.initial_state.index = index.shift(-1)[:1]
621
622    @Appender(MLEResults.summary.__doc__)
623    def summary(self, alpha=.05, start=None):
624        specification = ['A']
625        if self.model.trend and self.model.damped_trend:
626            specification.append('Ad')
627        elif self.model.trend:
628            specification.append('A')
629        else:
630            specification.append('N')
631        if self.model.seasonal:
632            specification.append('A')
633        else:
634            specification.append('N')
635
636        model_name = 'ETS(' + ', '.join(specification) + ')'
637
638        summary = super(ExponentialSmoothingResults, self).summary(
639            alpha=alpha, start=start, title='Exponential Smoothing Results',
640            model_name=model_name)
641
642        if self.model.initialization_method != 'estimated':
643            params = np.array(self.initial_state)
644            if params.ndim > 1:
645                params = params[0]
646            names = self.model.state_names
647            param_header = ['initialization method: %s'
648                            % self.model.initialization_method]
649            params_stubs = names
650            params_data = [[forg(params[i], prec=4)]
651                           for i in range(len(params))]
652
653            initial_state_table = SimpleTable(params_data,
654                                              param_header,
655                                              params_stubs,
656                                              txt_fmt=fmt_params)
657            summary.tables.insert(-1, initial_state_table)
658
659        return summary
660
661
662class ExponentialSmoothingResultsWrapper(MLEResultsWrapper):
663    _attrs = {}
664    _wrap_attrs = wrap.union_dicts(MLEResultsWrapper._wrap_attrs,
665                                   _attrs)
666    _methods = {}
667    _wrap_methods = wrap.union_dicts(MLEResultsWrapper._wrap_methods,
668                                     _methods)
669wrap.populate_wrapper(ExponentialSmoothingResultsWrapper,  # noqa:E305
670                      ExponentialSmoothingResults)
671