Time Series Stationarity Explained
Stationarity is the foundation of time series forecasting. A stationary time series has statistical properties that don't change over time. Specifically, three conditions must hold:
Key Insights
- Stationarity requires constant mean, variance, and autocovariance over time—without it, most forecasting models produce unreliable predictions
- Use both visual inspection (rolling statistics, ACF plots) and statistical tests (ADF, KPSS) together, as each catches different forms of non-stationarity
- Differencing removes trends, log transforms stabilize variance, and seasonal decomposition handles periodic patterns—apply transformations systematically until tests confirm stationarity
What is Stationarity?
Stationarity is the foundation of time series forecasting. A stationary time series has statistical properties that don’t change over time. Specifically, three conditions must hold:
- Constant mean: The average value doesn’t trend up or down
- Constant variance: The spread of values remains consistent
- Constant autocovariance: The relationship between observations at different lags stays the same
Why does this matter? Most forecasting models—ARIMA, VAR, and many machine learning approaches—assume stationarity. They learn patterns from historical data and project them forward. If the underlying statistics shift over time, these learned patterns become meaningless.
Let’s visualize the difference:
import numpy as np
import matplotlib.pyplot as plt
np.random.seed(42)
time = np.arange(0, 200)
# Stationary series: white noise
stationary = np.random.normal(0, 1, 200)
# Non-stationary series: random walk with trend
non_stationary = np.cumsum(np.random.normal(0.1, 1, 200))
fig, axes = plt.subplots(1, 2, figsize=(14, 4))
axes[0].plot(time, stationary)
axes[0].set_title('Stationary Series')
axes[0].set_xlabel('Time')
axes[0].axhline(y=0, color='r', linestyle='--', alpha=0.3)
axes[1].plot(time, non_stationary)
axes[1].set_title('Non-Stationary Series')
axes[1].set_xlabel('Time')
plt.tight_layout()
plt.show()
The stationary series oscillates around a constant mean. The non-stationary series wanders with no tendency to revert to a fixed level—classic random walk behavior.
Types of Stationarity
Strict stationarity means the joint probability distribution of any subset of observations is identical across time shifts. If you take observations at times t₁, t₂, t₃, their distribution matches observations at t₁+k, t₂+k, t₃+k for any k. This is mathematically elegant but practically impossible to verify.
Weak stationarity (or covariance stationarity) only requires constant mean, variance, and autocovariance. This is what we actually use. When practitioners say “stationary,” they mean weak stationarity.
Here’s the distinction in code:
import numpy as np
np.random.seed(42)
n = 1000
# Weak stationary: Normal distribution with constant parameters
weak_stationary = np.random.normal(5, 2, n)
# Strict stationary example: uniform distribution (also weak stationary)
strict_stationary = np.random.uniform(-3, 3, n)
# Not even weak stationary: increasing variance
time = np.arange(n)
increasing_var = np.random.normal(0, 1 + time/200, n)
print(f"Weak stationary - Mean: {weak_stationary.mean():.2f}, Std: {weak_stationary.std():.2f}")
print(f"Strict stationary - Mean: {strict_stationary.mean():.2f}, Std: {strict_stationary.std():.2f}")
print(f"Non-stationary - First half std: {increasing_var[:500].std():.2f}, Second half std: {increasing_var[500:].std():.2f}")
For time series forecasting, weak stationarity is sufficient and testable. Focus your efforts there.
Visual Tests for Stationarity
Before running statistical tests, plot your data. Visual inspection catches obvious issues fast.
Rolling statistics reveal trends and changing variance:
import pandas as pd
import matplotlib.pyplot as plt
# Generate non-stationary data with trend
np.random.seed(42)
date_range = pd.date_range('2020-01-01', periods=300, freq='D')
trend = np.linspace(100, 150, 300)
seasonal = 10 * np.sin(np.linspace(0, 8*np.pi, 300))
noise = np.random.normal(0, 5, 300)
data = trend + seasonal + noise
ts = pd.Series(data, index=date_range)
# Calculate rolling statistics
rolling_mean = ts.rolling(window=30).mean()
rolling_std = ts.rolling(window=30).std()
fig, ax = plt.subplots(figsize=(12, 6))
ax.plot(ts, label='Original', alpha=0.7)
ax.plot(rolling_mean, label='Rolling Mean (30-day)', linewidth=2)
ax.plot(rolling_std, label='Rolling Std (30-day)', linewidth=2)
ax.legend()
ax.set_title('Rolling Statistics Test')
plt.show()
If the rolling mean trends upward or the rolling standard deviation changes significantly, you’ve got non-stationarity.
ACF and PACF plots show autocorrelation structure:
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf
fig, axes = plt.subplots(1, 2, figsize=(14, 4))
plot_acf(ts, lags=40, ax=axes[0])
axes[0].set_title('Autocorrelation Function')
plot_pacf(ts, lags=40, ax=axes[1])
axes[1].set_title('Partial Autocorrelation Function')
plt.tight_layout()
plt.show()
Stationary series show ACF that drops quickly to zero. Non-stationary series have ACF that decays very slowly—a telltale sign of trends or random walks.
Statistical Tests for Stationarity
Visual tests are subjective. Statistical tests provide objective confirmation.
Augmented Dickey-Fuller (ADF) Test: The null hypothesis is that the series has a unit root (non-stationary). Low p-values (< 0.05) reject the null, indicating stationarity.
KPSS Test: The null hypothesis is that the series is stationary. Low p-values (< 0.05) reject the null, indicating non-stationarity.
Use both tests together. They’re complementary:
from statsmodels.tsa.stattools import adfuller, kpss
import yfinance as yf
# Download real stock data
stock_data = yf.download('AAPL', start='2020-01-01', end='2023-12-31', progress=False)
prices = stock_data['Close']
def test_stationarity(series, name):
print(f"\n{'='*50}")
print(f"Stationarity Tests for {name}")
print('='*50)
# ADF Test
adf_result = adfuller(series.dropna(), autolag='AIC')
print(f"\nADF Test:")
print(f" Test Statistic: {adf_result[0]:.4f}")
print(f" P-value: {adf_result[1]:.4f}")
print(f" Result: {'Stationary' if adf_result[1] < 0.05 else 'Non-Stationary'}")
# KPSS Test
kpss_result = kpss(series.dropna(), regression='c', nlags='auto')
print(f"\nKPSS Test:")
print(f" Test Statistic: {kpss_result[0]:.4f}")
print(f" P-value: {kpss_result[1]:.4f}")
print(f" Result: {'Stationary' if kpss_result[1] > 0.05 else 'Non-Stationary'}")
test_stationarity(prices, "AAPL Stock Prices")
Stock prices are almost always non-stationary (random walk behavior). Both tests should confirm this.
Making Data Stationary
Once you’ve confirmed non-stationarity, apply transformations systematically.
Differencing removes trends by subtracting consecutive observations:
import pandas as pd
import numpy as np
# First-order differencing
prices_diff = prices.diff().dropna()
# Seasonal differencing (for weekly patterns, use lag=7)
seasonal_diff = prices.diff(7).dropna()
test_stationarity(prices_diff, "First-Order Differenced Prices")
First-order differencing usually handles trends. For seasonal patterns, use seasonal differencing with appropriate lag.
Log transformation stabilizes variance:
# Log transform then difference
log_prices = np.log(prices)
log_returns = log_prices.diff().dropna()
test_stationarity(log_returns, "Log Returns")
Log returns are the standard transformation for financial data. They’re typically stationary and easier to model.
Box-Cox transformation automatically finds the optimal power transformation:
from scipy.stats import boxcox
# Box-Cox requires positive values
if (prices > 0).all():
prices_boxcox, lambda_param = boxcox(prices)
print(f"Optimal lambda: {lambda_param:.4f}")
prices_boxcox_diff = pd.Series(prices_boxcox, index=prices.index).diff().dropna()
test_stationarity(prices_boxcox_diff, "Box-Cox Transformed & Differenced")
Box-Cox is powerful but less interpretable than log transforms. Use it when variance changes dramatically over time.
Seasonal decomposition separates trend, seasonal, and residual components:
from statsmodels.tsa.seasonal import seasonal_decompose
# Decompose the series
decomposition = seasonal_decompose(prices, model='multiplicative', period=30)
# The residual component is often stationary
residual = decomposition.resid.dropna()
test_stationarity(residual, "Residual Component")
# Plot decomposition
fig, axes = plt.subplots(4, 1, figsize=(12, 10))
decomposition.observed.plot(ax=axes[0], title='Original')
decomposition.trend.plot(ax=axes[1], title='Trend')
decomposition.seasonal.plot(ax=axes[2], title='Seasonal')
decomposition.resid.plot(ax=axes[3], title='Residual')
plt.tight_layout()
plt.show()
Practical Workflow
Here’s a complete pipeline from raw data to stationary series:
import pandas as pd
import numpy as np
from statsmodels.tsa.stattools import adfuller, kpss
import matplotlib.pyplot as plt
def make_stationary(series, max_diff=2):
"""
Systematically transform series to achieve stationarity.
Returns transformed series and list of transformations applied.
"""
transformations = []
current = series.copy()
# Step 1: Check if already stationary
adf_pval = adfuller(current.dropna())[1]
kpss_pval = kpss(current.dropna(), regression='c', nlags='auto')[1]
if adf_pval < 0.05 and kpss_pval > 0.05:
print("Series is already stationary!")
return current, transformations
# Step 2: Try log transform if variance increases
rolling_std = current.rolling(window=30).std()
if rolling_std.iloc[-30:].mean() > rolling_std.iloc[:30].mean() * 1.5:
if (current > 0).all():
current = np.log(current)
transformations.append("log")
print("Applied log transformation")
# Step 3: Apply differencing
for d in range(1, max_diff + 1):
current = current.diff().dropna()
transformations.append(f"diff_{d}")
print(f"Applied differencing order {d}")
adf_pval = adfuller(current.dropna())[1]
kpss_pval = kpss(current.dropna(), regression='c', nlags='auto')[1]
if adf_pval < 0.05 and kpss_pval > 0.05:
print(f"Achieved stationarity after {d} differencing step(s)")
break
return current, transformations
# Apply to stock data
stationary_series, transforms = make_stationary(prices)
print(f"\nTransformations applied: {' -> '.join(transforms)}")
print(f"Original series length: {len(prices)}")
print(f"Stationary series length: {len(stationary_series)}")
# Visualize final result
fig, axes = plt.subplots(2, 1, figsize=(12, 8))
prices.plot(ax=axes[0], title='Original Series')
stationary_series.plot(ax=axes[1], title='Stationary Series')
plt.tight_layout()
plt.show()
This workflow handles most real-world cases. Start with visual inspection, confirm with statistical tests, apply transformations systematically, and retest until both ADF and KPSS agree the series is stationary.
Remember: differencing loses observations (first observation for first-order, first seven for seasonal with lag 7). Plan your data collection accordingly. And always document your transformations—you’ll need to reverse them when interpreting forecasts.
Stationarity isn’t just a theoretical requirement. It’s the difference between models that work and models that fail silently. Test rigorously, transform methodically, and verify before modeling.