Skip to content
AI360Xpert

Stationarity Testing

Stationarity testing formally verifies whether a time series has stable statistical properties over time — a prerequisite for ARIMA and most classical forecasting models, and the first diagnostic step in any time series analysis.

Stationarity testing formally verifies whether a time series has stable statistical properties over time — a prerequisite for ARIMA and most classical forecasting models, and the first diagnostic step in any time series analysis.
Stationarity testing formally verifies whether a time series has stable statistical properties over time — a prerequisite for ARIMA and most classical forecasting models, and the first diagnostic step in any time series analysis.

Why Does This Exist?

ARIMA, VAR, and most classical time series models assume the series is stationary — that the mean and variance don't change over time. Fitting these models to a non-stationary series (one with a trend or explosive variance) produces unreliable, often completely wrong estimates.

The Augmented Dickey-Fuller (ADF) and KPSS tests provide formal statistical procedures to decide whether a series needs to be transformed (via differencing or log transformation) before modeling.

Think of It Like This

Testing whether a car is level before aligning the wheels

Wheel alignment assumes the car is on a level surface. If the floor is tilted, the measurements are meaningless. Similarly, time series models assume the series is stationary. Testing for stationarity is checking whether the statistical "floor" is level before running any analysis on top of it.

How It Actually Works

What stationarity means

A strictly stationary series has the same joint distribution for any lag — all statistical properties are time-invariant. In practice, we use the weaker weak (covariance) stationarity:

  1. Constant mean: E[yt]=μt\mathbb{E}[y_t] = \mu \quad \forall t
  2. Constant variance: Var(yt)=σ2t\text{Var}(y_t) = \sigma^2 \quad \forall t
  3. Autocovariance depends only on lag: Cov(yt,ytk)=γkt\text{Cov}(y_t, y_{t-k}) = \gamma_k \quad \forall t

A trending series violates condition 1. A series with increasing volatility violates condition 2.

Augmented Dickey-Fuller (ADF) test

The ADF test is the most widely used stationarity test. It tests for a unit root — the formal cause of non-stationarity in most financial and economic series.

Null hypothesis (H0H_0): The series has a unit root (is non-stationary).
Alternative (H1H_1): The series is stationary.

The test regresses Δyt=α+βt+γyt1+i=1pδiΔyti+εt\Delta y_t = \alpha + \beta t + \gamma y_{t-1} + \sum_{i=1}^{p} \delta_i \Delta y_{t-i} + \varepsilon_t and tests H0:γ=0H_0: \gamma = 0.

Interpretation:

  • p-value < 0.05: Reject H0H_0 → series is stationary (no unit root)
  • p-value ≥ 0.05: Fail to reject H0H_0 → series likely has a unit root (non-stationary)

KPSS test (complementary)

The KPSS test reverses the null and alternative:

Null hypothesis (H0H_0): The series IS stationary.
Alternative (H1H_1): The series has a unit root.

Used alongside ADF to distinguish "has a unit root" from "trend-stationary":

ADF resultKPSS resultConclusion
Reject H0H_0Fail to reject H0H_0Stationary ✓
Fail to reject H0H_0Reject H0H_0Unit root (difference to fix)
Fail to reject H0H_0Fail to reject H0H_0Trend-stationary (detrend to fix)
Reject H0H_0Reject H0H_0Insufficient data / structural break

Making a series stationary

ProblemTransformation
Linear trendFirst difference: Δyt=ytyt1\Delta y_t = y_t - y_{t-1}
Exponential trendLog then first difference: Δlogyt\Delta \log y_t
Seasonal non-stationaritySeasonal difference: Δmyt=ytytm\Delta_m y_t = y_t - y_{t-m}
Increasing varianceLog or Box-Cox transform

The order of integration I(d)I(d) is the number of differences needed to achieve stationarity. I(0)I(0) = already stationary; I(1)I(1) = needs 1 difference (most economic series); I(2)I(2) = needs 2 differences (rare).

Code

import numpy as npimport pandas as pdfrom statsmodels.tsa.stattools import adfuller, kpss
# ── Generate non-stationary and stationary series ─────────────────────────────np.random.seed(42)n = 200
# Non-stationary: random walk (unit root)y_nonstat = np.cumsum(np.random.randn(n))
# Stationary: mean-reverting AR(1) with |φ| < 1y_stat = np.zeros(n)for t in range(1, n):    y_stat[t] = 0.7 * y_stat[t-1] + np.random.randn()

def run_stationarity_tests(series, name):    print(f"\n{'='*50}")    print(f"Series: {name}")    print(f"Mean: {series.mean():.3f}, Std: {series.std():.3f}")        # ── ADF test ──────────────────────────────────────────────────────────────    adf_result = adfuller(series, autolag='AIC')    print(f"\nADF Test:")    print(f"  Test statistic: {adf_result[0]:.4f}")    print(f"  p-value:        {adf_result[1]:.4f}")    print(f"  Lags used:      {adf_result[2]}")    print(f"  Critical values: 1%={adf_result[4]['1%']:.3f}  "          f"5%={adf_result[4]['5%']:.3f}  10%={adf_result[4]['10%']:.3f}")    adf_conclusion = "STATIONARY" if adf_result[1] < 0.05 else "NON-STATIONARY (unit root)"    print(f"  Conclusion: {adf_conclusion}")        # ── KPSS test ─────────────────────────────────────────────────────────────    kpss_result = kpss(series, regression='c', nlags='auto')    print(f"\nKPSS Test:")    print(f"  Test statistic: {kpss_result[0]:.4f}")    print(f"  p-value:        {kpss_result[1]:.4f}")    kpss_conclusion = "NON-STATIONARY" if kpss_result[1] < 0.05 else "STATIONARY"    print(f"  Conclusion: {kpss_conclusion}")        # ── Differencing if needed ────────────────────────────────────────────────    if adf_result[1] >= 0.05:        series_diff = np.diff(series)        adf_diff = adfuller(series_diff, autolag='AIC')        print(f"\nAfter 1st differencing:")        print(f"  ADF p-value: {adf_diff[1]:.4f} → "              f"{'STATIONARY ✓' if adf_diff[1] < 0.05 else 'still non-stationary'}")

run_stationarity_tests(y_nonstat, "Random Walk (non-stationary)")run_stationarity_tests(y_stat,    "AR(1) φ=0.7 (stationary)")
# ── Real-world example: test then difference ──────────────────────────────────print("\n--- ADF decision function ---")def ensure_stationary(series, max_diffs=3):    """Difference until stationary, return transformed series and order."""    for d in range(max_diffs + 1):        result = adfuller(series, autolag='AIC')        if result[1] < 0.05:            print(f"  Stationary at d={d} (p={result[1]:.4f})")            return series, d        print(f"  d={d}: p={result[1]:.4f} → applying difference")        series = np.diff(series)    print("  WARNING: could not achieve stationarity in {max_diffs} differences")    return series, max_diffs
_, integration_order = ensure_stationary(y_nonstat)

Watch Out For

Conflating stationarity tests with certainty

ADF and KPSS are hypothesis tests with Type I and Type II errors. For short series (< 100 observations), both tests have low power — they may fail to detect non-stationarity even when it's present. Supplement formal tests with visual inspection of rolling mean and variance, and with the ACF correlogram.

Over-differencing

Differencing once too many times (applying Δ\Delta to an already-stationary series) introduces spurious moving-average components and makes the series harder to model. Use the minimum number of differences needed to pass the ADF test. If ADF already rejects H0H_0 at d=0d=0, the series is already stationary — do not difference.

The Quick Version

  • Stationarity = constant mean, variance, and autocovariance structure over time.
  • ADF test: H0H_0 = unit root (non-stationary); reject H0H_0 (p < 0.05) → stationary.
  • KPSS test: H0H_0 = stationary; reject H0H_0 (p < 0.05) → non-stationary.
  • Use both tests together — they help distinguish unit-root non-stationarity from trend-stationarity.
  • Transform to achieve stationarity: first difference for linear trends, log for exponential growth, seasonal difference for seasonal patterns.