Tuesday, September 8, 2026
HomeData ScienceTime Series Analysis with Python – statsmodels, Prophet & LSTM 2026

Time Series Analysis with Python – statsmodels, Prophet & LSTM 2026

Table of Content

Time series data is everywhere — stock prices, sales figures, website traffic, sensor readings, energy consumption. Unlike cross-sectional data, time series observations are ordered and dependent on past values. This guide covers the full time series analysis workflow: decomposition, stationarity, ARIMA, Prophet, and LSTM, so you can confidently tackle any forecasting problem.

Time Series Decomposition

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from statsmodels.tsa.seasonal import seasonal_decompose, STL

# Generate sample sales data with trend + seasonality + noise
np.random.seed(42)
dates  = pd.date_range('2020-01-01', '2025-12-31', freq='ME')
trend  = np.linspace(100, 200, len(dates))
season = 20 * np.sin(2 * np.pi * np.arange(len(dates)) / 12)
noise  = np.random.normal(0, 5, len(dates))

ts = pd.Series(trend + season + noise, index=dates, name='sales')

# Classical decomposition
decomp = seasonal_decompose(ts, model='additive', period=12)

fig, axes = plt.subplots(4, 1, figsize=(12, 10))
decomp.observed.plot(ax=axes[0], title='Observed')
decomp.trend.plot(ax=axes[1], title='Trend')
decomp.seasonal.plot(ax=axes[2], title='Seasonality')
decomp.resid.plot(ax=axes[3], title='Residuals')
plt.tight_layout(); plt.show()

# STL decomposition (more robust, handles outliers better)
stl = STL(ts, period=12, robust=True)
res = stl.fit()
res.plot(); plt.show()

print(f'Seasonal strength: {max(0, 1 - res.resid.var() / (res.seasonal + res.resid).var()):.3f}')
print(f'Trend strength:    {max(0, 1 - res.resid.var() / (res.trend + res.resid).var()):.3f}')

Stationarity Tests

person holding pink sticky note
Photo by David Travis on Unsplash
from statsmodels.tsa.stattools import adfuller, kpss

def check_stationarity(series, name='Series'):
    print(f'=== Stationarity Check: {name} ===')

    # ADF test: H0=non-stationary, reject H0 → stationary
    adf_result = adfuller(series.dropna(), autolag='AIC')
    print(f'ADF p-value: {adf_result[1]:.4f} '
          f'({"✅ stationary" if adf_result[1] < 0.05 else "❌ non-stationary"})')

    # KPSS test: H0=stationary, reject H0 → non-stationary
    kpss_result = kpss(series.dropna(), regression='c', nlags='auto')
    print(f'KPSS p-value: {kpss_result[1]:.4f} '
          f'({"✅ stationary" if kpss_result[1] > 0.05 else "❌ non-stationary"})')
    print()

check_stationarity(ts, 'Raw Sales')

# Make stationary by differencing
ts_diff = ts.diff().dropna()
check_stationarity(ts_diff, 'First Difference')

ARIMA / SARIMA with statsmodels

from statsmodels.tsa.arima.model import ARIMA
from statsmodels.tsa.statespace.sarimax import SARIMAX
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf
from sklearn.metrics import mean_absolute_error, mean_squared_error

# ACF/PACF to identify p, d, q
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 6))
plot_acf(ts_diff, lags=24, ax=ax1)
plot_pacf(ts_diff, lags=24, ax=ax2)
plt.tight_layout(); plt.show()

# Train/test split (last 12 months = test)
train = ts[:-12]
test  = ts[-12:]

# SARIMA(p,d,q)(P,D,Q,s) — handles seasonal data
model = SARIMAX(train,
                order=(1, 1, 1),
                seasonal_order=(1, 1, 1, 12),
                enforce_stationarity=False,
                enforce_invertibility=False)

result = model.fit(disp=False)
print(result.summary())

# Forecast
forecast = result.get_forecast(steps=12)
mean_fc  = forecast.predicted_mean
ci       = forecast.conf_int()

plt.figure(figsize=(12, 5))
train.plot(label='Train'); test.plot(label='Actual')
mean_fc.plot(label='Forecast', color='red')
plt.fill_between(ci.index, ci.iloc[:, 0], ci.iloc[:, 1],
                 alpha=0.2, color='red', label='95% CI')
plt.legend(); plt.title('SARIMA Forecast'); plt.show()

mae  = mean_absolute_error(test, mean_fc)
rmse = np.sqrt(mean_squared_error(test, mean_fc))
mape = (abs(test - mean_fc) / abs(test)).mean() * 100
print(f'MAE: {mae:.2f} | RMSE: {rmse:.2f} | MAPE: {mape:.1f}%')

Facebook Prophet

a blue logo with a white cross
Photo by Eyestetix Studio on Unsplash
pip install prophet

from prophet import Prophet
from prophet.plot import plot_plotly, plot_components_plotly

# Prophet requires 'ds' (date) and 'y' (value) columns
df_prophet = ts.reset_index()
df_prophet.columns = ['ds', 'y']

train_p = df_prophet[:-12]
test_p  = df_prophet[-12:]

m = Prophet(
    yearly_seasonality=True,
    weekly_seasonality=False,
    daily_seasonality=False,
    seasonality_mode='additive',   # or 'multiplicative'
    changepoint_prior_scale=0.05,  # trend flexibility (0.001–0.5)
    seasonality_prior_scale=10,    # seasonality strength
    interval_width=0.95
)

# Add custom seasonality
m.add_seasonality(name='quarterly', period=91.25, fourier_order=5)

# Add regressors (external variables)
# m.add_regressor('holiday_flag')

m.fit(train_p)

# Make future dataframe
future   = m.make_future_dataframe(periods=12, freq='ME')
forecast = m.predict(future)

# Plot
fig1 = m.plot(forecast); plt.title('Prophet Forecast')
fig2 = m.plot_components(forecast)
plt.show()

# Evaluate
pred  = forecast.tail(12)['yhat'].values
actua = test_p['y'].values
mape  = np.mean(np.abs((actua - pred) / actua)) * 100
print(f'Prophet MAPE: {mape:.1f}%')

LSTM for Time Series

import torch
import torch.nn as nn
from sklearn.preprocessing import MinMaxScaler

# Prepare sequences
scaler     = MinMaxScaler()
ts_scaled  = scaler.fit_transform(ts.values.reshape(-1, 1))

SEQ_LEN = 12

def make_sequences(data, seq_len):
    X, y = [], []
    for i in range(len(data) - seq_len):
        X.append(data[i:i + seq_len])
        y.append(data[i + seq_len])
    return np.array(X), np.array(y)

X, y   = make_sequences(ts_scaled, SEQ_LEN)
split  = len(X) - 12
X_tr, X_te = X[:split], X[split:]
y_tr, y_te = y[:split], y[split:]

X_tr = torch.FloatTensor(X_tr)
X_te = torch.FloatTensor(X_te)
y_tr = torch.FloatTensor(y_tr)
y_te = torch.FloatTensor(y_te)

class LSTMForecaster(nn.Module):
    def __init__(self, input_size=1, hidden=64, layers=2, dropout=0.2):
        super().__init__()
        self.lstm = nn.LSTM(input_size, hidden, layers,
                             batch_first=True, dropout=dropout)
        self.fc   = nn.Linear(hidden, 1)

    def forward(self, x):
        out, _ = self.lstm(x)
        return self.fc(out[:, -1, :])

model_lstm = LSTMForecaster()
optimiser  = torch.optim.Adam(model_lstm.parameters(), lr=1e-3)
criterion  = nn.MSELoss()

for epoch in range(200):
    model_lstm.train()
    pred  = model_lstm(X_tr)
    loss  = criterion(pred, y_tr)
    optimiser.zero_grad(); loss.backward(); optimiser.step()
    if (epoch + 1) % 50 == 0:
        print(f'Epoch {epoch+1}: Loss={loss.item():.6f}')

model_lstm.eval()
with torch.no_grad():
    preds = model_lstm(X_te).numpy()
preds = scaler.inverse_transform(preds)
actua = scaler.inverse_transform(y_te.numpy())
mape  = np.mean(np.abs((actua - preds) / actua)) * 100
print(f'LSTM MAPE: {mape:.1f}%')

Choosing the Right Model

Use ARIMA/SARIMA for short, stationary series with clear seasonality and when interpretability matters — it is fast and gives confidence intervals natively. Use Prophet when your series has multiple seasonalities, holidays, or structural change points you want to specify — it handles missing data and outliers gracefully. Use LSTM when you have 2+ years of high-frequency data (daily or finer), multiple related series, or patterns that classical models cannot capture. In practice, always baseline with SARIMA before investing in LSTM — the complexity is only worth it if you have enough data and the MAPE improvement justifies it.

Conclusion

Time series forecasting is part statistics, part domain knowledge. The best model is rarely the most complex one. Always start with decomposition to understand what you are dealing with — trend, seasonality, cycles, noise. Verify stationarity before fitting ARIMA. Use Prophet as your fast, production-ready workhorse for business forecasting. Reserve LSTM for high-data, high-complexity scenarios. And always validate on a holdout set that is temporally separated from training — cross-validation must respect the time ordering of your data.

Leave feedback about this

  • Rating

Durgesh Kekare
Durgesh Kekarehttps://www.dataexpertise.in
Durgesh Kekare is a data science educator and founder of DataExpertise.in. With expertise in Python, machine learning, and analytics, he helps 10,000+ learners break into data careers.

Latest Posts

List of Categories