A simple moving average treats all past observations equally — last month's sales count as much as a year-old sale. Exponential smoothing fixes this by assigning exponentially decreasing weights to older observations: the most recent value gets weight α, the one before gets α(1−α), the one before that gets α(1−α)², and so on. Smaller α means more smoothing and slower adaptation to change; larger α means the forecast follows recent values closely. The family extends naturally to handle trends (Holt's method) and seasonality (Holt-Winters), making it a practical first choice for short-term forecasting when you want something simple, fast, and interpretable. ### Simulating a time series with trend and seasonality Five years of monthly data with a linear trend and a 12-month seasonal cycle provides a realistic forecasting target.
import numpy as np
import pandas as pd
rng = np.random.default_rng(42)
n = 60 # 5 years of monthly data
t = np.arange(n)
trend = 100 + 1.5 * t
seasonal = 20 * np.sin(2 * np.pi * t / 12)
noise = rng.normal(0, 5, n)
values = trend + seasonal + noise
dates = pd.date_range("2019-01", periods=n, freq="MS")
ts = pd.Series(values, index=dates, name="sales")
print(ts.describe().round(1))- `trend = 100 + 1.5 * t` adds 1.5 units per month — noticeable growth that pure level smoothing will systematically underforecast. - A 12-month seasonal amplitude of ±20 represents strong yearly seasonality. Holt-Winters should capture this; simple exponential smoothing cannot. - Splitting at month 48 (last 12 months held out for testing) gives exactly one full seasonal cycle as the test set. ### Simple exponential smoothing Simple exponential smoothing (SES) estimates only the level. It works well for series with no trend and no seasonality.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from statsmodels.tsa.holtwinters import SimpleExpSmoothing
rng = np.random.default_rng(42)
n = 60
t = np.arange(n)
ts = pd.Series(100 + 1.5*t + 20*np.sin(2*np.pi*t/12) + rng.normal(0, 5, n),
index=pd.date_range("2019-01", periods=n, freq="MS"))
train, test = ts.iloc[:48], ts.iloc[48:]
ses = SimpleExpSmoothing(train).fit(smoothing_level=0.3, optimized=False)
forecast = ses.forecast(len(test))
fig, ax = plt.subplots(figsize=(11, 4))
train.plot(ax=ax, label="Train", color="steelblue")
test.plot(ax=ax, label="Test (actual)", color="gray", linestyle="--")
forecast.plot(ax=ax, label="SES (α=0.3)", color="tomato")
ax.set_title("Simple Exponential Smoothing")
ax.legend()
plt.tight_layout()
plt.show()
rmse = np.sqrt(((test.values - forecast.values)**2).mean())
print(f"Test RMSE: {rmse:.2f}")- `smoothing_level=0.3` with `optimized=False` fixes α for illustration. `optimized=True` (the default) would find the α that minimises in-sample error. - SES forecasts a flat line: all future values equal the last estimated level. It cannot follow a trend or seasonal pattern, so its RMSE will be large on this data. - `ses.forecast(n)` generates n steps ahead. The flat forecast visually confirms SES is the wrong model when trend and seasonality are present. ### Holt-Winters triple exponential smoothing Holt-Winters adds smoothing parameters for trend (β) and seasonality (γ), allowing it to forecast series that grow and cycle.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from statsmodels.tsa.holtwinters import ExponentialSmoothing
rng = np.random.default_rng(42)
n = 60
t = np.arange(n)
ts = pd.Series(100 + 1.5*t + 20*np.sin(2*np.pi*t/12) + rng.normal(0, 5, n),
index=pd.date_range("2019-01", periods=n, freq="MS"))
train, test = ts.iloc[:48], ts.iloc[48:]
hw = ExponentialSmoothing(
train, trend="add", seasonal="add", seasonal_periods=12
).fit()
forecast = hw.forecast(len(test))
fig, ax = plt.subplots(figsize=(11, 4))
train.plot(ax=ax, label="Train", color="steelblue")
test.plot(ax=ax, label="Test (actual)", color="gray", linestyle="--")
forecast.plot(ax=ax, label="Holt-Winters (add/add)", color="tomato")
ax.set_title("Holt-Winters Exponential Smoothing")
ax.legend()
plt.tight_layout()
plt.show()
rmse_hw = np.sqrt(((test.values - forecast.values)**2).mean())
print(f"Holt-Winters Test RMSE: {rmse_hw:.2f}")
print(f"Smoothing params — α: {hw.params['smoothing_level']:.3f}, "
f"β: {hw.params['smoothing_trend']:.3f}, "
f"γ: {hw.params['smoothing_seasonal']:.3f}")- `trend="add"` fits an additive trend component (linear growth). Use `trend="mul"` when growth is a fixed percentage per period. - `seasonal="add"` fits an additive seasonal component. Use `seasonal="mul"` when seasonal swings are proportional to the level — for example, holiday sales that are 30% above average regardless of whether the baseline is 100 or 200. - `seasonal_periods=12` must be set explicitly for monthly data. The training set must contain at least two full seasonal cycles (24 months here) for stable estimation. ### Conclusion Exponential smoothing is the most practical starting point for short-horizon forecasting: no distributional assumptions, trains in milliseconds, and has interpretable parameters. Choose SES for stationary series, Holt's double smoothing for series with trend but no seasonality, and Holt-Winters when both are present. Always validate on a held-out test period of at least one full seasonal cycle to confirm the model generalises. For a richer ARIMA framework that can also model autocorrelated residuals, see [ARIMA models with statsmodels](/tutorials/statsmodels-arima-models). For decomposing the components before forecasting, see [time series decomposition](/tutorials/time-series-decomposition-statsmodels).