Time Series Forecasting in Python: Trend, Seasonality, and Residuals

Time Series Forecasting in Python: Trend, Seasonality, and Residuals

August 31, 2026

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.873625

Step 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:

  1. Observed: The raw data.
  2. Trend: A smooth upward curve — the underlying growth.
  3. Seasonal: A clean 12-month repeating wave.
  4. 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 needed

The 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 stationary

One 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 statsmodelsSTL decomposition or Facebook Prophet.
  • Exogenous regressors: ARIMAX models include external drivers (holiday flags, marketing spend). Use the exog parameter in ARIMA.
  • Long-range dependencies: Fractional differencing (d not an integer) via ARFIMA for series with long memory.

Practical Tips for Real-World Forecasting

ChallengeApproach
Missing datesResample to a consistent frequency with .resample().ffill() or .interpolate()
OutliersWinsorize or treat as NaN and interpolate. Outliers distort decomposition.
Irregular samplingConvert to regular frequency; consider statsmodelsUnobservedComponents for irregular data
Multiple seasonal periodsUse STL (seasonal-trend decomposition using LOESS) or Prophet
Model selectionNever 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