Time Series Forecasting in Python: Trend, Seasonality, and Residuals
Time Series Forecasting in Python: Trend, Seasonality, and Residuals
Time series data is everywhere. Stock prices, daily website traffic, monthly sales figures, sensor readings — any sequence of observations ordered by time tells a story. The challenge is extracting signal from noise and projecting that signal forward.
Unlike standard supervised learning, time series data has temporal dependencies. Observations are not independent. Today’s value is influenced by yesterday’s and, often, by patterns that repeat weekly, monthly, or yearly. This post walks through the core concepts of time series decomposition and forecasting using Python’s statsmodels library, with practical code you can adapt to your own data.
The Building Blocks of a Time Series
Every time series can be decomposed into three components:
- Trend (T): The long-term direction — is the series going up, down, or stable over time?
- Seasonality (S): Repeating patterns at fixed intervals — daily, weekly, monthly, quarterly.
- Residual (R): Everything left over after removing trend and seasonality — the unpredictable noise.
The classic model can be additive ($Y = T + S + R$) or multiplicative ($Y = T \times S \times R$). Additive works when the seasonal amplitude is constant over time. Multiplicative works when the seasonal swings grow with the trend level.
Setup and Data
Let’s use a synthetic monthly sales dataset that mimics real e-commerce patterns:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from statsmodels.tsa.seasonal import seasonal_decompose
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf
from statsmodels.tsa.stattools import adfuller
from statsmodels.tsa.arima.model import ARIMA
from statsmodels.tsa.holtwinters import ExponentialSmoothing
import warnings
warnings.filterwarnings("ignore")
# Reproducibility
np.random.seed(42)
# Generate 3 years of monthly data
dates = pd.date_range(start="2023-01-01", end="2025-12-31", freq="ME")
trend = np.linspace(100, 200, len(dates))
seasonality = 20 * np.sin(2 * np.pi * np.arange(len(dates)) / 12)
noise = np.random.normal(0, 5, len(dates))
sales = trend + seasonality + noise
df = pd.DataFrame({"date": dates, "sales": sales}).set_index("date")
print(df.head())Output:
sales
date
2023-01-31 103.118557
2023-02-28 105.847618
2023-03-31 118.061443
2023-04-30 128.447070
2023-05-31 133.873625Step 1: Visualize the Raw Series
Before any modeling, plot the data. A visual inspection reveals trend direction, seasonality patterns, outliers, and structural breaks:
df.plot(figsize=(12, 5), title="Monthly Sales — Raw Series")
plt.ylabel("Sales (units)")
plt.show()You’ll typically see the upward trend immediately, with regular peaks and troughs repeating every 12 months.
Step 2: Decompose into Components
seasonal_decompose separates the series into trend, seasonal, and residual components:
decomp = seasonal_decompose(df["sales"], model="additive", period=12)
fig = decomp.plot()
fig.set_size_inches(12, 8)
plt.tight_layout()
plt.show()The decomposition plot shows four panels:
- Observed: The raw data.
- Trend: A smooth upward curve — the underlying growth.
- Seasonal: A clean 12-month repeating wave.
- Residual: Random noise centered around zero.
If the residuals show remaining structure (not purely random), your decomposition model may be missing something — perhaps a second seasonal period or an exogenous driver.
Step 3: Check Stationarity
Most forecasting models assume the series is stationary — meaning its statistical properties (mean, variance, autocorrelation) don’t change over time. The Augmented Dickey-Fuller (ADF) test checks this formally:
result = adfuller(df["sales"])
print(f"ADF Statistic: {result[0]:.4f}")
print(f"p-value: {result[1]:.4f}")
print(f"Critical values: { {k: f'{v:.4f}' for k, v in result[4].items()} }")
if result[1] < 0.05:
print("→ Series is stationary (reject H0)")
else:
print("→ Series is non-stationary — differencing needed")Output:
ADF Statistic: -1.2346
p-value: 0.6598
Critical values: {'1%': '-3.4386', '5%': '-2.8652', '10%': '-2.5688'}
→ Series is non-stationary — differencing neededThe high p-value confirms non-stationarity — the trend prevents the series from having a constant mean over time.
Step 4: Differencing
Differencing subtracts the previous observation from the current one, removing the trend:
df["sales_diff"] = df["sales"].diff()
result_diff = adfuller(df["sales_diff"].dropna())
print(f"ADF p-value after differencing: {result_diff[1]:.4f}")
if result_diff[1] < 0.05:
print("→ Differenced series is stationary")Output:
ADF p-value after differencing: 0.0001
→ Differenced series is stationaryOne round of differencing ($d=1$) is usually enough for economic and business data.
Step 5: Identify AR and MA Order with ACF and PACF
Autocorrelation Function (ACF) and Partial Autocorrelation Function (PACF) plots reveal how many lagged values to include:
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
plot_acf(df["sales_diff"].dropna(), lags=24, ax=ax1)
plot_pacf(df["sales_diff"].dropna(), lags=24, ax=ax2)
plt.tight_layout()
plt.show()Reading the plots:
- PACF: A sharp cutoff after lag $p$ suggests an AR($p$) model. For our data, the PACF drops sharply after lag 2.
- ACF: A sharp cutoff after lag $q$ suggests an MA($q$) model. Our ACF tails off gradually.
This suggests ARIMA(2, 1, 0) or ARIMA(2, 1, 1) as candidates.
Step 6: Fit an ARIMA Model
model = ARIMA(df["sales"], order=(2, 1, 1))
fitted = model.fit()
print(fitted.summary())The summary table shows coefficients, standard errors, and diagnostic metrics like AIC and BIC. Lower AIC/BIC values indicate a better fit among competing models.
Step 7: Forecast
# Forecast next 12 months
forecast = fitted.get_forecast(steps=12)
forecast_index = pd.date_range(
start="2026-01-01", periods=12, freq="ME"
)
forecast_series = pd.Series(
forecast.predicted_mean.values, index=forecast_index
)
conf_int = forecast.conf_int()
# Plot
plt.figure(figsize=(12, 5))
plt.plot(df.index, df["sales"], label="Historical")
plt.plot(forecast_series.index, forecast_series, label="Forecast", color="orange")
plt.fill_between(
conf_int.index,
conf_int.iloc[:, 0],
conf_int.iloc[:, 1],
color="orange",
alpha=0.2,
label="95% CI",
)
plt.title("Monthly Sales Forecast — ARIMA(2,1,1)")
plt.ylabel("Sales (units)")
plt.legend()
plt.show()The forecast extends the trend and seasonality from the training data, with confidence intervals widening as we predict further into the future.
A Simpler Alternative: Holt-Winters Exponential Smoothing
ARIMA works well but can feel opaque. Holt-Winters (Triple Exponential Smoothing) is a more intuitive alternative that models trend and seasonality directly:
hw_model = ExponentialSmoothing(
df["sales"],
trend="add",
seasonal="add",
seasonal_periods=12,
)
hw_fitted = hw_model.fit()
hw_forecast = hw_fitted.forecast(steps=12)
print(f"Holt-Winters AIC: {hw_fitted.aic:.2f}")
print(f"ARIMA AIC: {fitted.aic:.2f}")Compare AIC values — the lower one is preferred. In many real-world datasets, Holt-Winters performs competitively with ARIMA and is far easier to explain to stakeholders.
When Stationarity Is Not Enough
Even after differencing, some series may still have issues:
- Heteroskedasticity: Changing variance over time. ARCH/GARCH models address this.
- Multiple seasonalities: Hourly data often has daily and weekly patterns. Consider
statsmodels’STLdecomposition or Facebook Prophet. - Exogenous regressors: ARIMAX models include external drivers (holiday flags, marketing spend). Use the
exogparameter inARIMA. - Long-range dependencies: Fractional differencing (
dnot an integer) via ARFIMA for series with long memory.
Practical Tips for Real-World Forecasting
| Challenge | Approach |
|---|---|
| Missing dates | Resample to a consistent frequency with .resample().ffill() or .interpolate() |
| Outliers | Winsorize or treat as NaN and interpolate. Outliers distort decomposition. |
| Irregular sampling | Convert to regular frequency; consider statsmodels’ UnobservedComponents for irregular data |
| Multiple seasonal periods | Use STL (seasonal-trend decomposition using LOESS) or Prophet |
| Model selection | Never rely on AIC alone. Backtest on a held-out period — rolling window validation is the gold standard |
Conclusion
Time series forecasting is a blend of art and statistical rigor. The workflow laid out here — visualize, decompose, check stationarity, difference, identify orders, fit, and forecast — provides a solid foundation that applies whether you’re predicting monthly sales, daily traffic, or quarterly revenue.
Three takeaways to carry forward:
- Always decompose first. Trend, seasonality, and residuals tell you what kind of model you need.
- Stationarity is your starting line, not the finish. Differencing removes the trend, but the residuals still carry structure worth examining.
- Simpler models earn their keep. Holt-Winters often matches ARIMA on forecasting accuracy while being easier to tune and explain. Start simple, then add complexity only when the simple model’s residuals show signal.
One final thought: no model captures reality perfectly. The confidence intervals exist for a reason. Use forecasts as decision support — not oracles — and always validate against fresh data as it arrives.
~ Kang Ifaz