Test whether a series has a unit root¶
Running ADF alone is the classic mistake: failing to reject a unit root is not
evidence of one, because ADF has low power near the boundary. The fix is the
confirmatory pair — ADF (null: unit root) and KPSS (null: stationary) —
read together. tsecon.check_stationarity runs both, classifies the result
into one of four quadrants, and tells you what to do next.
The recipe¶
import numpy as np, textwrap, tsecon
rng = np.random.default_rng(7)
walk = np.cumsum(rng.standard_normal(300)) # a genuine unit root
r = tsecon.check_stationarity(walk)
print(f"{r['quadrant']} -> {r['recommendation']}")
print(f"ADF stat {r['adf_statistic']:+.3f} p {r['adf_p_value']:.3f}")
print(f"KPSS stat {r['kpss_statistic']:+.3f} p {r['kpss_p_value']:.3f}")
print(textwrap.fill(r["interpretation"], 72))
UnitRoot -> Difference
ADF stat -1.691 p 0.436
KPSS stat +2.625 p 0.010
At the 5% level ADF cannot reject a unit root and KPSS rejects
stationarity — the tests agree the series looks I(1). Difference it once
and re-run this battery on the differences before modeling; regressing
I(1) levels on each other risks spurious regression unless you are
explicitly testing for cointegration.
Reading the quadrant¶
quadrant is the whole point. The two tests have opposite nulls, so their
four combinations mean four different things:
| Quadrant | ADF | KPSS | What it means |
|---|---|---|---|
Stationary |
rejects | does not reject | Both agree on I(0). Proceed in levels. |
UnitRoot |
does not reject | rejects | Both agree on I(1). Difference. |
Conflict |
rejects | rejects | Often a deterministic trend or a structural break, not a root. |
Inconclusive |
neither rejects | neither rejects | The sample cannot tell. Say so. |
Only the two agreeing quadrants are conclusions. Conflict and Inconclusive
are the honest outcomes the single-test workflow hides from you, and the
interpretation string names the follow-up in each case.
Then confirm the differences are what you thought:
d = tsecon.check_stationarity(np.diff(walk))
print(f"first differences: {d['quadrant']} -> {d['recommendation']}")
When to reach for Phillips-Perron¶
tsecon.phillips_perron tests the same null as ADF but gets there differently:
it runs the Dickey-Fuller regression with no lagged differences and then
corrects the statistic with a Bartlett kernel estimate of the residual long-run
variance. Same nonstandard null distribution, so the same MacKinnon p-values
apply.
pp = tsecon.phillips_perron(walk, regression="c", test_type="tau")
print(f"PP Z-tau {pp['stat']:+.3f} p {pp['pvalue']:.3f} bandwidth {pp['lags']}")
print("5% critical value:", round(pp["crit"]["5%"], 3))
Use it when you would rather not choose an augmentation lag length, when the
errors are heteroskedastic in an unknown way, or as a robustness cross-check —
agreement between ADF and PP is reassuring. Prefer ADF when you suspect a
large negative MA component, where PP's size distortion is worst. And quote
lags: a PP result without its bandwidth is not reproducible.
Gotchas¶
- Match the deterministics to your alternative.
regression="c"(default) tests against a stationary-around-a-constant alternative; use"ct"when the series trends, or a genuine trend will masquerade as a root. - A break looks like a root. A stationary series whose mean jumps
mid-sample fools ADF (Perron 1989). If the quadrant comes back
UnitRootorConflicton a series you suspect has a break, run the break scan before differencing. - KPSS p-values are interpolated and clamped to
[0.01, 0.10]. A reportedp = 0.01means "at most 0.01", andp = 0.10means "at least 0.10" — they are table endpoints, not measurements. Settingalphaoutside that range makes the quadrant verdict meaningless even though the call succeeds. - Testing several series at once?
tsecon.check_serieson a 2-D array gives per-series verdicts plus a Johansen cointegration test.