Stationarity Testing With ADF and KPSS
A steady series keeps the same mean and spread over time. These two tests check that steadiness before you trust a forecast.
Why Does This Exist?
ARIMA and most classical forecasters assume the past looks like the future in one narrow sense: same average, same spread, same lag behaviour. Feed them a climbing series and they learn a mean that no longer exists. Your intervals look tight in training, then the live series walks away from them.
You need a verdict before you model: steady or wandering, and which fix applies. Eyeballing helps, but two series can look alike while one needs differencing and the other needs detrending. The Augmented Dickey-Fuller and KPSS tests give you that verdict with a clear null each, so you stop guessing.
This page teaches the two tests, how to read them together, and the smallest fix that steadies the data. What steadiness means from zero lives in time series fundamentals. Splitting trend from season before you test lives in time-series decomposition. The model that needs the steady input is ARIMA.
Basics first: weak stationarity means constant mean, constant variance, and covariances that depend only on lag , not on time . If that triple is new, read fundamentals first. Everything below uses it.
Think of It Like This
Checking the floor before you align wheels
Wheel alignment assumes a level floor. On a tilt, every laser reading bends the same way and your fix corrects a slope that is not in the car.
Stationarity is that level floor. The tests check whether the statistical floor tilts before you run analysis on top of it. Differencing is the shim that levels it.
Where it stops: a level floor stays level. A steadied series can still break later if the world shifts. That later shift is drift in production, not non-stationarity in training, and no test on old data can rule it out.
How It Actually Works
A weakly stationary series keeps three promises for every and every lag . Mean stays put, so . Spread stays put, so . Lag behaviour stays put, so with no on the right. A trend breaks the first. Growing volatility breaks the second. A changing season breaks the third.
The ADF test hunts a unit root, the formal cause of most wandering. Its null says a unit root exists, meaning non-stationary. Its alternative says the series is stationary. So a small p-value below 0.05 rejects wandering and blesses the levels. A large p-value keeps the wandering verdict and sends you to differencing.
The KPSS test flips the burden. Its null says stationary. Its alternative says a unit root exists. So a small p-value below 0.05 rejects steadiness. A large p-value keeps the steady verdict.
Read them together, because each errs alone:
- ADF rejects wandering and KPSS keeps steady means stationary. Model the levels.
- ADF keeps wandering and KPSS rejects steady means a unit root. Difference once and retest.
- Both keep their nulls means trend-stationary. Remove the trend rather than differencing blindly.
- Both reject means too little data or a break in the middle. Plot rolling means and check autocorrelation before you difference.
The fixes are small and ordered. First differences remove a straight trend. Seasonal differences remove a fixed season with period . A log steadies spread that grows with level. The count of differences you needed is the integration order : 0 means already steady, 1 covers most business series, 2 is rare.
Worked example
Six weeks of signups: 5, 7, 9, 11, 13, 15. Overall mean is .
- First half mean is .
- Second half mean is .
- The mean moved 7 to 13, so levels fail the first promise.
First differences are 2, 2, 2, 2, 2, with mean . Every difference equals 2, so spread is 0 and the mean holds at 2 across halves. One difference turned wandering into steady. That is exactly what the diagram shows: drifting dots left, flat line at 2 right.
Code
This stdlib check mirrors the diagram: compare half means on levels, then on differences. No external tests, just the drift made visible:
def mean(xs: list[float]) -> float: return sum(xs) / len(xs)
def differences(xs: list[float]) -> list[float]: return [b - a for a, b in zip(xs, xs[1:])]
levels: list[float] = [5.0, 7.0, 9.0, 11.0, 13.0, 15.0]print(mean(levels)) # -> 10.0print(mean(levels[:3])) # -> 7.0print(mean(levels[3:])) # -> 13.0diffs: list[float] = differences(levels)print(diffs) # -> [2.0, 2.0, 2.0, 2.0, 2.0]print(mean(diffs)) # -> 2.0Levels drift 7.0 to 13.0 while differences sit at 2.0 throughout. That gap is your signal to difference once, then run ADF and KPSS on the steadied series.
Watch Out For
Trusting ADF on a short series
You run both tests on 40 points, get large p-values everywhere, and declare the series trend-stationary. The symptom is a confident verdict from starved data: rolling means still slope, yet you model levels because the test did not reject.
Fix it by plotting rolling mean and variance plus the correlogram before you believe a keep-the-null result. Under about 100 points both tests run weak, so visual drift overrules a quiet p-value.
Differencing a steady series
You difference because the pipeline always differences. The symptom is new trouble: a clean series gains a false moving-average wiggle, variance roughly doubles, and forecasts swing around the truth they used to hit.
Fix it by differencing only to the verdict. If ADF already rejects wandering at and KPSS keeps steady, stop. The smallest that steadies the mean wins.
The Quick Version
- Stationarity means steady mean, steady spread, and lag behaviour that ignores clock time.
- ADF null says wandering. Below 0.05 means steady enough to model in levels.
- KPSS null says steady. Below 0.05 means wandering, so transform first.
- Six levels from 5 to 15 drift 7 to 13, while differences hold at 2.
- Difference straight trends, seasonally difference fixed seasons, log growing spread.
- Short series fool both tests, so confirm with rolling means and the correlogram.