The simulated file is the known-truth laboratory: every test can be scored against an answer we already have. The four real datasets are where the answer is unknown.
Each step conditions the next. The integration order decides whether cointegration testing is even meaningful; the cointegrating rank decides the shape of the VECM; the VECM is what makes the impulse responses interpretable.
Companion decks
Vector Autoregressive Methods and Local Projections develops the stationary VAR that sits underneath the VECM.
Bayesian Computation in Econometrics covers the Bayesian VAR alternative to the rank tests used here.
Part I — Integration and Spurious Regression
βαθύς γέ τοι Διρκαῖος ἀναχωρεῖν πόρος.
deep, to be sure, is the Dircean crossing to fall back through
a \(t\)-statistic on \(\hat\beta\) that appears overwhelmingly significant
a Durbin-Watson statistic close to zero
The diagnostic that costs nothing: if \(R^2 > DW\) in a levels regression of \(I(1)\) variables, suspect a spurious regression.
Write the regression as
\[
y_t = \alpha + \beta x_t + u_t
\]
Standard inference needs \(u_t \sim I(0)\). When \(y_t\) and \(x_t\) are both \(I(1)\) and not cointegrated, \(u_t\) is itself \(I(1)\) — it never returns to its mean, so the usual variance formulas do not apply.
OLS minimises \(\sum \hat u_t^2\) regardless. In any finite sample two independent random walks will share an apparent trend by chance, and OLS finds the linear combination that best exploits it.
\[
t_{\hat\beta} \;\xrightarrow{d}\; \text{a non-degenerate random variable, not } N(0,1)
\]
The \(t\)-statistic diverges with \(T\) rather than settling down. More data makes the illusion stronger, not weaker.
Warning
If you regress \(I(1)\) variables without testing for cointegration, the results may be meaningless even with a \(p\)-value of 0.001. The whole of Parts I–III exists to keep you out of this trap.
The two escape routes:
the variables are not cointegrated — model in first differences, accepting the loss of long-run information
the variables are cointegrated — the levels regression is meaningful after all, and the error correction model of Part V is the right specification
Which one applies is an empirical question, and it is answered by the tests that follow.
The three tabs analyse the same two columns of ur-vecm-sim.csv, so the coefficient, \(R^2\) and DW agree exactly across languages.
What to look at, in order:
\(R^2\) — high, and it has no economic content whatsoever. The two series were generated independently.
\(t\)-statistic — large. Under the usual asymptotics this would be decisive evidence. It is evidence of nothing.
DW — close to zero, the signature of \(I(1)\) residuals. \(R^2 > DW\) is the Granger-Newbold rule of thumb firing.
More data makes it worse, not better
Re-run the simulation with \(T = 50\), \(T = 100\) and \(T = 500\). The \(R^2\) does not shrink and the \(t\)-statistic grows.
That is the point: the problem is not a small-sample artefact that more data will cure. Under the null of no cointegration the \(t\)-statistic diverges, so a larger sample buys a more confident wrong answer.
where \(W(\cdot)\) is standard Brownian motion. The distribution is skewed to the left, so the critical values are negative and must be obtained by simulation — this is what Dickey and Fuller tabulated.
One problem, restated in every test of Part II
Every test in Part II is a variation on this single problem: how to get a usable critical value for \(\hat\rho - 1\) when the null makes the regressor non-stationary.
The tests differ in how they buy back power — augmentation, a kernel correction, GLS detrending, a break search — but the obstacle they are working around is always this one.
DGP — Mathematical Specification
Five series with known integration order, plus a cointegrated pair, all with \(\varepsilon_t \sim N(0,1)\) and seed 14159.
Because \(u_t\) is a stationary AR(1), the combination \(y_t^{(c)} - 2x_t\) is \(I(0)\) by construction: the pair is cointegrated with cointegrating vector \([1, -2]'\) and the error correction term is \(u_t\).
The three panels on the left show what integration order looks like before any test is run.
\(I(0)\) oscillates around a fixed level and crosses its mean repeatedly.
\(I(1)\) wanders. It has no level to return to, and long excursions away from zero are normal rather than exceptional.
\(I(1)\) with drift adds a deterministic slope on top of the wandering, which is why the ADF specification with a trend matters.
The right-hand panel is the whole idea of cointegration in one picture: y_c and x_c are each \(I(1)\) and neither reverts, but the specific combination \(y_c - 2x_c\) does. The summary statistics in the Stata tab make the same point numerically — the first-order autocorrelation is near 0.6 for the \(I(0)\) series and close to 1 for both \(I(1)\) series.
Reject \(H_0\) when \(\tau < \tau_{\text{crit}}\). This is a left-tail test and the critical values are negative.
The lagged differences \(\Delta y_{t-j}\) are the “augmentation”: they soak up serial correlation so that \(\varepsilon_t\) is white noise, which is what the tabulated distribution assumes.
Spec
Intercept
Trend
Use when
trend
Yes
Yes
The series visibly trends
drift
Yes
No
Wanders around a non-zero mean, no trend
none
No
No
Centred at zero — in practice only for residuals
Under-specifying the deterministic terms
Choosing drift for a trending series under-specifies the regression and biases the test toward rejecting the unit root.
Plot the series first, every time. The deterministic specification is part of the null being tested, not a display option.
Lag selection. Too few lags leaves serial correlation in the residuals and oversizes the test; too many costs power. AIC is the usual default; the Schwert rule \(p_{\max} = \lfloor 12(T/100)^{1/4}\rfloor\) sets a sensible upper bound.
summary(ur.df(...)) prints tau3 (the \(t\)-statistic on the lagged level with a trend) together with phi2 and phi3, which are joint \(F\)-tests on the deterministic terms. For the unit-root decision only tau3 matters:
Then test the first difference, with type = "drift" because differencing removes the trend:
tau2 statistic: -7.32
Critical values: 5pct -2.89
-7.32 < -2.89 -> reject H0 -> the difference is I(0) -> the series is I(1)
Two rejections in that order is what “the series is \(I(1)\)” actually means. A single failure to reject on the level proves nothing on its own — it is equally consistent with \(I(2)\).
sim_df <-read.csv("../data/ur-vecm-sim.csv")# tseries: quick p-value, Schwert lag ruleadf_i0 <- tseries::adf.test(sim_df$i0)adf_i1 <- tseries::adf.test(sim_df$i1)print(adf_i0); print(adf_i1)# urca: full regression table and the choice of specificationur_i1 <- urca::ur.df(sim_df$i1, type ="trend", lags =4, selectlags ="AIC")summary(ur_i1)
--- ADF on the I(0) series ---
Augmented Dickey-Fuller Test
data: sim_df$i0
Dickey-Fuller = -5.6571, Lag order = 6, p-value = 0.01
alternative hypothesis: stationary
--- ADF on the I(1) series ---
Augmented Dickey-Fuller Test
data: sim_df$i1
Dickey-Fuller = -2.0079, Lag order = 6, p-value = 0.573
alternative hypothesis: stationary
--- urca::ur.df, trend specification, I(1) series ---
Differenced: tau2 = -8.3727 5pct = -2.87 -> reject, difference is I(0), series is I(1)
Code
import pandas as pdfrom statsmodels.tsa.stattools import adfullersim = pd.read_csv("../data/ur-vecm-sim.csv")lines = []for col, label in [("i0", "I(0)"), ("i1", "I(1)")]: res = adfuller(sim[col], autolag="AIC", regression="ct") lines.append(f"--- ADF on {label} ({col}) ---") lines.append(f" statistic : {res[0]:.4f}") lines.append(f" p-value : {res[1]:.4f}") lines.append(f" lags used : {res[2]}")for k, v in res[4].items(): lines.append(f" CV {k:>3} : {v:.4f}") lines.append(" verdict : "+ ("reject H0 (stationary)"if res[1] <0.05else"fail to reject H0 (unit root)"))d = adfuller(sim["i1"].diff().dropna(), autolag="AIC", regression="c")lines.append(f"\nFirst difference of i1: stat = {d[0]:.4f}, p = {d[1]:.4f}")out ="\n".join(lines)import sys; sys.stdout.write(out +"\n"); sys.stdout.flush()
--- ADF on I(0) (i0) ---
statistic : -8.9543
p-value : 0.0000
lags used : 0
CV 1% : -3.9894
CV 5% : -3.4253
CV 10% : -3.1357
verdict : reject H0 (stationary)
--- ADF on I(1) (i1) ---
statistic : -2.1344
p-value : 0.5268
lags used : 5
CV 1% : -3.9899
CV 5% : -3.4255
CV 10% : -3.1359
verdict : fail to reject H0 (unit root)
First difference of i1: stat = -8.3727, p = 0.0000
415
Code
quietly import delimited "../data/ur-vecm-sim.csv", clearquietlydestring_all, replacequietlytsset tdisplay"--- ADF on the I(0) series ---"dfuller i0, trend lags(4)display"--- ADF on the I(1) series ---"dfuller i1, trend lags(4)display"--- ADF on the first difference of the I(1) series ---"dfuller D.i1, lags(4)
--- ADF on the I(0) series ---
Augmented Dickey–Fuller test for unit root
Variable: i0 Number of obs = 295
Number of lags = 4
H0: Random walk with or without drift
Dickey–Fuller
Test -------- critical value ---------
statistic 1% 5% 10%
--------------------------------------------------------------
Z(t) -5.661 -3.988 -3.428 -3.130
--------------------------------------------------------------
MacKinnon approximate p-value for Z(t) = 0.0000.
--- ADF on the I(1) series ---
Augmented Dickey–Fuller test for unit root
Variable: i1 Number of obs = 295
Number of lags = 4
H0: Random walk with or without drift
Dickey–Fuller
Test -------- critical value ---------
statistic 1% 5% 10%
--------------------------------------------------------------
Z(t) -2.254 -3.988 -3.428 -3.130
--------------------------------------------------------------
MacKinnon approximate p-value for Z(t) = 0.4598.
--- ADF on the first difference of the I(1) series ---
Augmented Dickey–Fuller test for unit root
Variable: D.i1 Number of obs = 294
Number of lags = 4
H0: Random walk without drift, d = 0
Dickey–Fuller
Test -------- critical value ---------
statistic 1% 5% 10%
--------------------------------------------------------------
Z(t) -8.373 -3.456 -2.878 -2.570
--------------------------------------------------------------
MacKinnon approximate p-value for Z(t) = 0.0000.
The known truth: i0 is a stationary AR(1) with \(\rho = 0.6\) and i1 is a pure random walk. A test that works should reject on i0 and fail to reject on i1 — and all three languages do exactly that on the same data.
The last line of each tab is the confirmation step. Differencing i1 produces a series on which the test rejects decisively, which is what pins the order at \(I(1)\) rather than \(I(2)\).
Interpolated p-values or tabulated critical values
tseries::adf.test and statsmodels.adfuller report an interpolated \(p\)-value; urca::ur.df and Stata’s dfuller report the statistic against tabulated critical values.
Both are the same test — only the presentation differs. Compare statistics with statistics across the three tabs, never a \(p\)-value in one against a critical value in another.
Phillips-Perron adds no lagged differences. Instead it corrects the statistic non-parametrically for whatever serial correlation is in \(u_t\), using the long-run variance
quietly import delimited "../data/ur-vecm-sim.csv", clearquietlydestring_all, replacequietlytsset tdisplay"--- PP on the I(0) series ---"pperron i0, trend lags(6)display"--- PP on the I(1) series ---"pperron i1, trend lags(6)
--- PP on the I(0) series ---
Phillips–Perron test for unit root Number of obs = 299
Variable: i0 Newey–West lags = 6
H0: Random walk with or without drift
Dickey–Fuller
Test -------- critical value ---------
statistic 1% 5% 10%
--------------------------------------------------------------
Z(rho) -127.165 -28.498 -21.339 -18.020
Z(t) -8.935 -3.988 -3.428 -3.130
--------------------------------------------------------------
MacKinnon approximate p-value for Z(t) = 0.0000.
--- PP on the I(1) series ---
Phillips–Perron test for unit root Number of obs = 299
Variable: i1 Newey–West lags = 6
H0: Random walk with or without drift
Dickey–Fuller
Test -------- critical value ---------
statistic 1% 5% 10%
--------------------------------------------------------------
Z(rho) -7.888 -28.498 -21.339 -18.020
Z(t) -2.098 -3.988 -3.428 -3.130
--------------------------------------------------------------
MacKinnon approximate p-value for Z(t) = 0.5471.
quietly import delimited "../data/ur-vecm-sim.csv", clearquietlydestring_all, replacequietlytsset tdisplay"--- KPSS on the I(0) series ---"kpss i0display"--- KPSS on the I(1) series ---"kpss i1
--- KPSS on the I(0) series ---
KPSS test for i0
Maxlag = 15 chosen by Schwert criterion
Autocovariances weighted by Bartlett kernel
Critical values for H0: i0 is trend stationary
10%: 0.119 5% : 0.146 2.5%: 0.176 1% : 0.216
Lag order Test statistic
0 .0931
1 .0593
2 .0474
3 .0414
4 .038
5 .0357
6 .0341
7 .033
8 .0322
9 .0319
10 .0318
11 .0318
12 .032
13 .0323
14 .0327
15 .0332
--- KPSS on the I(1) series ---
KPSS test for i1
Maxlag = 15 chosen by Schwert criterion
Autocovariances weighted by Bartlett kernel
Critical values for H0: i1 is trend stationary
10%: 0.119 5% : 0.146 2.5%: 0.176 1% : 0.216
Lag order Test statistic
0 4.28
1 2.17
2 1.46
3 1.11
4 .896
5 .755
6 .654
7 .579
8 .52
9 .473
10 .435
11 .402
12 .375
13 .352
14 .332
15 .314
Elliott, Rothenberg & Stock (1996): remove the deterministic part by GLSbefore running the Dickey-Fuller regression. The gain in power against near-unit-root alternatives is large.
Step 1 — quasi-difference with \(\bar\alpha = 1 + \bar c/T\) and regress:
\[
\tilde y_t = y_t - \hat\delta' z_t
\]
with \(z_t = (1,t)'\) and \(\bar c = -13.5\) in the trend case, \(\bar c = -7\) with a constant only.
Step 2 — ADF on the detrended series, no deterministic terms:
Estimating an intercept and trend by OLS costs power, because under the null those estimates are contaminated by the stochastic trend. GLS detrending at a local alternative \(\rho = 1 + \bar c/T\) estimates them under conditions close to where the power actually matters.
The result is a test with near-optimal power in the sense of the Neyman-Pearson envelope. In practice: DF-GLS rejects at \(\rho = 0.95\) where ADF frequently does not.
Why DF-GLS should be the default
When power matters — and with macro series of 100–300 observations it always does — DF-GLS is the better default than plain ADF.
The cost is nothing but a different set of critical values, which every implementation already carries.
DF-GLS — Code
Code
ers_i0 <- urca::ur.ers(sim_df$i0, type ="DF-GLS", model ="trend", lag.max =8)ers_i1 <- urca::ur.ers(sim_df$i1, type ="DF-GLS", model ="trend", lag.max =8)summary(ers_i0)summary(ers_i1)
quietly import delimited "../data/ur-vecm-sim.csv", clearquietlydestring_all, replacequietlytsset tdisplay"--- DF-GLS on the I(0) series ---"dfgls i0, maxlag(8)display"--- DF-GLS on the I(1) series ---"dfgls i1, maxlag(8)
--- DF-GLS on the I(0) series ---
DF-GLS test for unit root Number of obs = 291
Variable: i0
Lag selection: User specified Maximum lag = 8
-------- Critical value ---------
[lags] DF-GLS tau 1% 5% 10%
--------------------------------------------------------------
8 -5.464 -3.480 -2.864 -2.579
7 -5.398 -3.480 -2.870 -2.585
6 -5.507 -3.480 -2.876 -2.590
5 -5.705 -3.480 -2.882 -2.595
4 -5.617 -3.480 -2.887 -2.600
3 -6.899 -3.480 -2.892 -2.605
2 -7.102 -3.480 -2.897 -2.609
1 -8.084 -3.480 -2.902 -2.613
--------------------------------------------------------------
Opt lag (Ng–Perron seq t) = 4 with RMSE = 1.02764
Min SIC = .1131372 at lag 1 with RMSE = 1.037768
Min MAIC = .5458488 at lag 4 with RMSE = 1.02764
--- DF-GLS on the I(1) series ---
DF-GLS test for unit root Number of obs = 291
Variable: i1
Lag selection: User specified Maximum lag = 8
-------- Critical value ---------
[lags] DF-GLS tau 1% 5% 10%
--------------------------------------------------------------
8 -1.169 -3.480 -2.864 -2.579
7 -1.090 -3.480 -2.870 -2.585
6 -1.180 -3.480 -2.876 -2.590
5 -1.307 -3.480 -2.882 -2.595
4 -1.520 -3.480 -2.887 -2.600
3 -1.681 -3.480 -2.892 -2.605
2 -1.548 -3.480 -2.897 -2.609
1 -1.379 -3.480 -2.902 -2.613
--------------------------------------------------------------
Opt lag (Ng–Perron seq t) = 5 with RMSE = .9669869
Min SIC = .005039 at lag 1 with RMSE = .9831668
Min MAIC = -.0200611 at lag 5 with RMSE = .9669869
The problem. A one-off level shift in a stationary series looks like a unit root to the ADF test. Perron (1989) showed that ignoring a known break biases the test badly toward failing to reject.
with \(DU_t(\lambda)=\mathbf{1}[t > \lambda T]\) a level break and \(DT_t(\lambda) = t\cdot\mathbf{1}[t > \lambda T]\) a trend break.
\[
ZA = \inf_{\lambda\in\Lambda} t_{\hat\alpha}(\lambda)
\]
The statistic is the minimum\(t\)-statistic over all candidate break fractions in a trimmed range, so its critical values are far more negative than the ADF ones.
The break is under \(H_1\), not \(H_0\). Rejecting means “stationary around a broken trend”, not “there is a break”.
Because the search takes an infimum, using ADF critical values would reject far too often.
With a break under the null as well, the ZA test over-rejects; Lee & Strazicich (2003) is the usual remedy.
The estimated break date is only informative when the rejection is decisive.
Trimming is not a detail
Trimming matters. The standard 15% at each end exists because a “break” in the first or last few observations is indistinguishable from an outlier.
Change the trimming and the infimum is taken over a different set, so the critical values no longer apply.
verdict : fail to reject H0 - unit root survives the break allowance
Code
import numpy as np, pandas as pdfrom arch.unitroot import ZivotAndrewssim = pd.read_csv("../data/ur-vecm-sim.csv")# method selects the lag length: "aic", "bic" or "t-stat". There is no# "both" option here - arch always allows a break in level and trend for# trend="ct".za = ZivotAndrews(sim["i1"], trend="ct", method="aic")za.stat
-4.21266964071403
Code
# arch computes the statistic at every candidate break but exposes only the# minimum, so recover the break date from the stored grid.grid = np.asarray(za._all_stats)brk =int(np.nanargmin(grid)) +1lines = ["--- Zivot-Andrews on the I(1) series ---",f" minimum t-statistic : {za.stat:.4f}",f" p-value : {za.pvalue:.4f}",f" break at observation: {brk} of {len(sim)}",f" lags : {za.lags}"," verdict : "+ ("reject H0"if za.pvalue <0.05else"fail to reject H0")]out ="\n".join(lines)import sys; sys.stdout.write(out +"\n"); sys.stdout.flush()
--- Zivot-Andrews on the I(1) series ---
minimum t-statistic : -4.2127
p-value : 0.3659
break at observation: 141 of 300
lags : 5
verdict : fail to reject H0
207
Code
quietly import delimited "../data/ur-vecm-sim.csv", clearquietlydestring_all, replacequietlytsset t* lagmethod is case-sensitive: AIC, BIC or TTest, notlowercasezandrews i1, lagmethod(AIC) break(both)
Zivot-Andrews unit root test for i1
Allowing for break in both intercept and trend
Lag selection via AIC: lags of D.i1 included = 0
Minimum t-statistic -4.290 at 141 (obs 141)
Critical values: 1%: -5.57 5%: -5.08 10%: -4.82
The series is a pure random walk with no break anywhere in it, so the correct answer is fail to reject — and all three tabs give that.
This is the useful case to see first. A test that searches 70% of the sample for the most favourable break date and still cannot reject is telling you the persistence is real, not an artefact of a level shift. Had the test rejected here it would have been a false positive, which is precisely the risk that the more negative critical values are there to control.
The break date the search lands on is meaningful only under rejection. Here it is simply the point where a spurious shift fits best, and it should not be reported as if the series had broken there.
Ng & Perron (2001) combine GLS detrending with modified M-statistics, aimed at the case where the errors have a moving-average root near \(-1\) — the configuration in which ADF and especially PP suffer severe size distortion.
From the GLS-detrended series \(\tilde y_t\) with long-run variance \(\hat\omega^2\):
\(MZ_\alpha\), \(MZ_t\) and \(MSB\) reject for small values; \(MP_T\) is a point-optimal statistic and also rejects for small values.
Trend case, \(\bar c = -13.5\), from Ng & Perron (2001) Table 1:
Statistic
1%
5%
10%
\(MZ_\alpha\)
\(-23.8\)
\(-17.3\)
\(-14.2\)
\(MZ_t\)
\(-3.42\)
\(-2.91\)
\(-2.62\)
\(MSB\)
\(0.143\)
\(0.168\)
\(0.185\)
\(MP_T\)
\(4.03\)
\(5.48\)
\(6.67\)
Constant-only case, \(\bar c = -7\): \(MZ_\alpha\)\(-8.10\), \(MZ_t\)\(-1.98\), \(MSB\)\(0.233\), \(MP_T\)\(3.17\) at 5%.
No package ships the M-tests
No R package exports these tests, statsmodels and arch do not implement them, and Stata’s dfgls reports the DF-GLS \(\tau\) rather than the M-statistics.
The next slide therefore codes them from the definitions above — the same twenty lines in all three languages, which is also the cleanest way to see what the statistics actually are.
The three implementations agree to four decimals, because they are the same arithmetic applied to the same column of the same CSV. That is the point of coding the test from its definition rather than calling three different packages: there is no version, default or convention left to disagree about.
On the known truth the verdicts are correct and emphatic. For i0, \(MZ_\alpha\) is far below the 5% value of \(-17.3\); for i1 it is nowhere near it, and \(MP_T\) is many times the critical value of 5.48.
Two silent implementation traps
Two implementation traps, both silent.
In Stata, -13.5^2 evaluates to \(-182.25\), because unary minus binds more loosely than the power operator. The parentheses in (-13.5)^2 are load-bearing, and their absence flips the sign of \(MP_T\) without any error.
\(MSB\) is the only one of the four statistics that is not negative under the alternative; comparing it to a negative critical value would reject always.
Step 3 — If the two disagree or both fail to reject, apply DF-GLS for power, or Ng-Perron if the residuals look MA, or Zivot-Andrews if a break is plausible.
Step 4 — If \(I(1)\) is not rejected, test \(\Delta y_t\). Rejecting there confirms \(I(1)\) rather than \(I(2)\).
Step 5 — With two or more \(I(1)\) series, move to cointegration testing in Part III.
Outcome
What to estimate
Series is \(I(0)\)
Use levels; a standard VAR is fine
\(I(1)\), not cointegrated
Difference, and model the VAR in differences
\(I(1)\) and cointegrated
ECM or VECM — Part V
\(I(2)\)
Difference twice, or look for polynomial cointegration
The two errors do not cost the same
The cost of the two errors is not symmetric.
Differencing a cointegrated system throws away the long-run relationship that is usually the object of interest.
Estimating in levels without cointegration gives a spurious regression.
The tests are how you choose, and Step 4 is the one most often skipped.
Engle & Granger (1987): the \(K\times 1\) vector \(\mathbf{y}_t \sim I(1)\) is cointegrated, written \(CI(1,1)\), if there exists a non-zero \(\boldsymbol\beta\) with
\[
\boldsymbol\beta'\mathbf{y}_t \sim I(0)
\]
\(\boldsymbol\beta\) is the cointegrating vector and \(\boldsymbol\beta'\mathbf{y}_t\) is the equilibrium error, or error correction term.
The cointegrating rank\(r\) is the number of linearly independent such vectors:
\(r = 0\) — no long-run relationship; model in differences
\(0 < r < K\) — \(r\) long-run relations and \(K - r\) common stochastic trends
\(r = K\) — every variable is already \(I(0)\); the integration order was misdiagnosed
In the bivariate case \(u_t = y_t - \beta x_t \sim I(0)\) and the vector is \([1, -\beta]'\).
If \(\mathbf{y}_t \sim CI(1,1)\) with rank \(r\), then an error correction representation must exist:
\(\boldsymbol\beta\) is \(K\times r\) — the long-run relations
\(\boldsymbol\alpha\) is \(K\times r\) — the loadings, how fast each variable corrects
\(\boldsymbol\Gamma_j\) is \(K\times K\) — short-run dynamics
\(\boldsymbol\Pi = \boldsymbol\alpha\boldsymbol\beta'\) is the long-run impact matrix, with \(\operatorname{rank}(\boldsymbol\Pi) = r\)
The theorem runs both ways: cointegration implies error correction, and error correction implies cointegration. This is why Part V is not an optional extra — it is the representation of what Part III tests for.
Cointegrated series share a common stochastic trend. They may drift apart in the short run, but the gap between them is stationary.
Standard examples, each with a theory behind the restriction:
log GDP and log consumption — the permanent income hypothesis
spot and futures prices — arbitrage
exchange rates and relative price levels — purchasing power parity
short and long interest rates — the expectations hypothesis
money, income, prices and interest — money demand, which is the four-variable system used from Part IV onwards
and \(\lvert\alpha_i\rvert\) measures the fraction of a disequilibrium corrected per quarter: \(\lvert\alpha\rvert \approx 0.05\) is very slow, \(\lvert\alpha\rvert \approx 1\) is complete correction within one period.
Engle-Granger Two-Step
Engle-Granger critical values
Standard ADF critical values are not valid for the residual test. The residuals are generated regressors, not the true errors, so the statistic has a distribution that depends on the number of variables in the cointegrating regression and on which deterministic terms were included.
Use the MacKinnon response-surface values instead. For two variables with a constant and no trend the 5% value is about \(-3.34\), against \(-2.89\) for a plain ADF — using the wrong one rejects far too often.
A less obvious technicality: even when the true process is a finite-order AR, the residual ADF regression needs its lag order to grow with the sample. The first-stage regression injects serial correlation into the residuals whatever the true process is, so a fixed small \(p\) biases the test.
OLS is superconsistent here: \(\hat\beta \to \beta\) at rate \(T\) rather than \(\sqrt{T}\). It is a fine way to estimate\(\beta\) — but the OLS standard errors are invalid, so it is not a way to do inference on \(\beta\).
sim_df <-read.csv("../data/ur-vecm-sim.csv")# Step 1: the static regression. True beta is 2.eg_fit <-lm(y_c ~ x_c, data = sim_df)cat(sprintf("beta_hat = %.4f (true value 2)\n", coef(eg_fit)[2]))# Step 2: Phillips-Ouliaris test, and an ADF on the residualspo <- tseries::po.test(cbind(sim_df$y_c, sim_df$x_c))print(po)eg_adf <- urca::ur.df(residuals(eg_fit), type ="none", lags =4,selectlags ="AIC")summary(eg_adf)
Step 1: beta_hat = 1.9978 (true value 2)
Phillips-Ouliaris Cointegration Test
data: cbind(sim_df$y_c, sim_df$x_c)
Phillips-Ouliaris demeaned = -173.76, Truncation lag parameter = 2,
p-value = 0.01
Step 2: ADF on residuals, tau = -9.2803
MacKinnon 5% value for 2 variables, no trend: -3.34
-> reject H0, the pair is cointegrated
Code
import pandas as pdimport statsmodels.api as smfrom statsmodels.tsa.stattools import coint, adfullersim = pd.read_csv("../data/ur-vecm-sim.csv")fit = sm.OLS(sim["y_c"], sm.add_constant(sim["x_c"])).fit()stat, pval, crit = coint(sim["y_c"], sim["x_c"])res = adfuller(fit.resid, autolag="AIC", regression="n")lines = [f"Step 1: beta_hat = {fit.params['x_c']:.4f} (true value 2)","",f"Step 2: Engle-Granger statistic = {stat:.4f} p = {pval:.4f}"]for k, v inzip(["1%", "5%", "10%"], crit): lines.append(f" CV {k:>3} : {v:.4f}")lines.append(" verdict : "+ ("reject H0, the pair is cointegrated"if pval <0.05else"fail to reject H0"))lines.append(f"\nADF on the residuals: stat = {res[0]:.4f} p = {res[1]:.4f}")out ="\n".join(lines)import sys; sys.stdout.write(out +"\n"); sys.stdout.flush()
Step 1: beta_hat = 1.9978 (true value 2)
Step 2: Engle-Granger statistic = -11.0911 p = 0.0000
CV 1% : -3.9334
CV 5% : -3.3566
CV 10% : -3.0587
verdict : reject H0, the pair is cointegrated
ADF on the residuals: stat = -11.0911 p = 0.0000
257
Step 1: static regression, true beta is 2
Source | SS df MS Number of obs = 300
-------------+---------------------------------- F(1, 298) > 99999.00
Model | 122164.151 1 122164.151 Prob > F = 0.0000
Residual | 93.7788879 298 .314694255 R-squared = 0.9992
-------------+---------------------------------- Adj R-squared = 0.9992
Total | 122257.93 299 408.889396 Root MSE = .56098
------------------------------------------------------------------------------
y_c | Coefficient Std. err. t P>|t| [95% conf. interval]
-------------+----------------------------------------------------------------
x_c | 1.997823 .0032065 623.06 0.000 1.991512 2.004133
_cons | .0029254 .0325825 0.09 0.929 -.0611956 .0670463
------------------------------------------------------------------------------
Step 2: Engle-Granger with MacKinnon critical values
Augmented Engle-Granger test for cointegration N (1st step) = 300
Number of lags = 4 N (test) = 295
------------------------------------------------------------------------------
Test 1% Critical 5% Critical 10% Critical
Statistic Value Value Value
------------------------------------------------------------------------------
Z(t) -6.559 -3.933 -3.357 -3.059
Critical values from MacKinnon (1990, 2010)
The known truth is \(\beta = 2\) and the pair is cointegrated, so a correct procedure recovers a slope near 2 and rejects the null of no cointegration. All three tabs do.
Two things worth noticing:
The slope is close to 2 despite the residual being a serially correlated AR(1) with \(\rho = 0.4\). That is superconsistency: the \(I(1)\) regressor dominates the stationary error so completely that the bias vanishes at rate \(T\).
The residual ADF statistic is compared against \(-3.34\), not \(-2.89\). Using the plain ADF value here would still reject, but on data where the evidence is weaker that difference decides the answer.
Johansen’s Likelihood Ratio Approach
Selecting the cointegrating rank
Sequential procedure. Test \(H_0: r = 0\); if rejected, test \(H_0: r \le 1\); continue until the first failure to reject. That value is \(\hat r\). Read the trace table from the top and stop at the first row you cannot reject.
Trace or max eigenvalue. Johansen recommends trace. The max-eigenvalue test is sharper when a particular cointegrating vector is the object of interest. They usually agree for \(r \le 2\); when they do not, prefer trace for the rank itself.
Deterministics change the answer. The five Johansen models have different critical values and can select different ranks on the same data. This is not a bug in any one package — it is the specification doing real work, and it must be chosen from the shape of the data, not by default.
Normalisation of the cointegrating vector
\(\boldsymbol\beta\) is identified only up to a non-singular transformation: if \(\boldsymbol\beta'\mathbf{y}_t \sim I(0)\) then so is \(c\boldsymbol\beta'\mathbf{y}_t\) for any \(c \ne 0\). A normalisation is needed before the numbers mean anything.
The usual convention, and the default in cajorls(), sets the first element to 1. The remaining elements are then long-run elasticities with the sign convention of the equation as written — which is why the coefficient on income prints as \(-\eta\) when the relation is written \(ECT = lrm - \eta\,ly + \dots\)
Restrictions on \(\boldsymbol\beta\) are testable: unit price homogeneity, a unit income elasticity, or the exclusion of a variable from the long run are all linear hypotheses with likelihood ratio tests. Part V runs one.
Everything hinges on \(\operatorname{rank}(\boldsymbol\Pi) = r\), estimated by reduced-rank maximum likelihood. With ordered eigenvalues \(\hat\lambda_1 \ge \cdots \ge \hat\lambda_K\):
\[
\lambda_{\text{trace}}(r) = -T\sum_{i=r+1}^{K}\ln(1-\hat\lambda_i) \qquad H_0: \operatorname{rank} \le r
\]
\[
\lambda_{\max}(r,r+1) = -T\ln(1-\hat\lambda_{r+1}) \qquad H_0: \operatorname{rank} = r
\]
Both have non-standard, Dickey-Fuller-type limiting distributions.
Model
Restriction
Typical use
\(H_0(0)\)
No constant, no trend
Rare
\(H_1(r)\)
Constant restricted to the ECT
No linear trend in levels
\(H_1^*(r)\)
Unrestricted constant
Most common — linear trend in levels
\(H_2(r)\)
Trend restricted to the ECT
Quadratic trend in levels
\(H_2^*(r)\)
Unrestricted trend
Rare
Lags in levels or lags in differences
The lag argument is in levels, not differences.ca.jo(K = 2) fits a VAR(2) in levels, whose VECM form has \(K - 1 = 1\) lag in differences. tsDyn::VECM(lag = 1) means the same model.
Passing 2 to both is the single most common implementation error in this literature, and it changes the estimates without raising an error.
sim_df <-read.csv("../data/ur-vecm-sim.csv")Y <-as.matrix(sim_df[, c("y_c", "x_c")])# Lag order for the VAR in levels. Guard the lower bound: ca.jo needs K >= 2,# and on strongly cointegrated data AIC often returns 1.lag_sel <- vars::VARselect(Y, lag.max =8, type ="const")k_opt <-max(2, lag_sel$selection["AIC(n)"])joh_trace <- urca::ca.jo(Y, type ="trace", ecdet ="const",K = k_opt, spec ="longrun")summary(joh_trace)joh_eigen <- urca::ca.jo(Y, type ="eigen", ecdet ="const",K = k_opt, spec ="longrun")summary(joh_eigen)cajorls(joh_trace, r =1)$beta
VARselect AIC suggests 1 lag(s); ca.jo requires K >= 2, using K = 2
Hypothesis Trace Trace_5pct MaxEigen MaxE_5pct
r = 0 77.3015 19.96 76.6197 15.67
r <= 1 0.6818 9.24 0.6818 9.24
Cointegrating vector, normalised on y_c (true value -2 on x_c):
import numpy as np, pandas as pdfrom statsmodels.tsa.vector_ar.vecm import coint_johansensim = pd.read_csv("../data/ur-vecm-sim.csv")Y = sim[["y_c", "x_c"]].valuesres = coint_johansen(Y, det_order=0, k_ar_diff=1)lines = [f"{'Hypothesis':<12}{'Trace':>12}{'5% CV':>10}"f"{'MaxEigen':>12}{'5% CV':>10}"]for r inrange(2): lines.append(f"{'r <= '+str(r):<12}{res.lr1[r]:>12.4f}{res.cvt[r,1]:>10.4f}"f"{res.lr2[r]:>12.4f}{res.cvm[r,1]:>10.4f}")# Normalise the first eigenvector on y_c so it is comparable with R and Statabeta = res.evec[:, 0] / res.evec[0, 0]lines.append(f"\nCointegrating vector normalised on y_c: "f"[{beta[0]:.4f}, {beta[1]:.4f}] (true value -2 on x_c)")out ="\n".join(lines)import sys; sys.stdout.write(out +"\n"); sys.stdout.flush()
Hypothesis Trace 5% CV MaxEigen 5% CV
r <= 0 76.7493 15.4943 76.6164 14.2639
r <= 1 0.1328 3.8415 0.1328 3.8415
Cointegrating vector normalised on y_c: [1.0000, -1.9977] (true value -2 on x_c)
255
Code
quietly import delimited "../data/ur-vecm-sim.csv", clearquietlydestring_all, replacequietlytsset tvarsoc y_c x_c, maxlag(8)vecrank y_c x_c, lags(2) trend(constant) maxquietlyvec y_c x_c, lags(2) trend(constant) rank(1)display""display"Cointegrating vector, normalised on y_c (true value -2 on x_c):"matrix b = e(beta)matrixlist b
The trace statistic at \(r = 0\) is enormous and far above its 5% value, while at \(r \le 1\) it is tiny and far below. The sequence therefore stops at \(\hat r = 1\), which is the truth: one cointegrating relationship among two \(I(1)\) variables, leaving one common stochastic trend.
The estimated vector normalises to roughly \([1, -2]\), recovering the \(\beta = 2\) built into the DGP.
Why the lag guard is load-bearing
VARselect chooses one lag on this data, and ca.jo refuses to run with \(K = 1\). The guard max(2, …) is not cosmetic — without it the chunk fails, and because the failure is an error rather than a wrong number it is the good kind of bug.
A silent version of the same problem is choosing the lag order without looking at it at all.
Pesaran, Shin & Smith (2001). The selling point: it works when the regressors are a mix of \(I(0)\) and \(I(1)\), so the integration order does not have to be settled first.
The \(F\)-statistic is compared against two critical values rather than one.
\(F\)-statistic
Conclusion
\(F > CV_{I(1)}\)
Long-run relationship, whatever the integration orders
\(F < CV_{I(0)}\)
No long-run relationship
\(CV_{I(0)} \le F \le CV_{I(1)}\)
Inconclusive — the orders must be established after all
The lower bound assumes every regressor is \(I(0)\), the upper bound that every regressor is \(I(1)\). Any real mixture lies between, which is what makes the test agnostic — and what makes the middle region genuinely undecidable rather than merely awkward.
A \(t\)-test on \(\theta_1\) alone gives a second, complementary bounds test.
What the bounds test assumes
The test assumes at most one long-run relation and that \(y\) is not weakly exogenous.
It is not a substitute for Johansen in a system where several long-run relations are plausible.
fred_df <-read.csv("../data/ur-vecm-fred.csv")# auto_ardl() returns a list; the fitted model is in $best_model.# Passing the list itself to bounds_f_test() is a common error.ardl_sel <- ARDL::auto_ardl(lgdp ~ lpce, data = fred_df,max_order =c(4, 4), selection ="AIC")ardl_fit <- ardl_sel$best_modelbounds_F <- ARDL::bounds_f_test(ardl_fit, case =3)bounds_t <- ARDL::bounds_t_test(ardl_fit, case =3)print(bounds_F)print(bounds_t)
Selected order: ARDL(1,2)
Bounds F-test: F = 7.5874 p = 0.0124
Bounds t-test: t = -3.8882 p = 0.0086
Case 3 (unrestricted constant), k = 1, 5% critical values:
F: I(0) 4.94 I(1) 5.73
t: I(0) -2.86 I(1) -3.22
Long-run multipliers:
Term Estimate Std. Error t value Pr(>|t|)
(Intercept) 1.0046 0.0471 21.3077 0
lpce 0.9352 0.0053 176.3389 0
Code
import pandas as pd, warningswarnings.filterwarnings("ignore")from statsmodels.tsa.ardl import ardl_select_order, UECMfred = pd.read_csv("../data/ur-vecm-fred.csv")# statsmodels moved bounds testing onto the unrestricted error correction# model: there is no bounds_f_test function to import.sel = ardl_select_order(fred["lgdp"], 4, fred[["lpce"]], 4, ic="aic", trend="c")uecm = UECM.from_ardl(sel.model).fit()bt = uecm.bounds_test(case=3)lines = [f"Selected order: AR lags {sel.model.ar_lags}, "f"DL lags {sel.model.dl_lags['lpce']}","",f"Bounds F-statistic: {float(bt.stat):.4f}",f" 5% critical values: I(0) {bt.crit_vals.loc[95.0, 'lower']:.3f} "f"I(1) {bt.crit_vals.loc[95.0, 'upper']:.3f}",f" p-values: lower {bt.p_values['lower']:.4f} "f"upper {bt.p_values['upper']:.4f}"]out ="\n".join(lines)import sys; sys.stdout.write(out +"\n"); sys.stdout.flush()
quietly import delimited "../data/ur-vecm-fred.csv", clearquietlydestring_all, replacequietlygen date2 = date(date, "YMD")quietlygen qdate = qofd(date2)quietlyformat qdate %tqquietlytsset qdate* btest requires the error correction form, so `ec` must be present tooardl lgdp lpce, aic maxlag(4) ec btest
ARDL(1,3) regression
Sample: 1954q2 thru 2026q2 Number of obs = 289
R-squared = 0.7050
Adj R-squared = 0.6998
Log likelihood = 1078.8339 Root MSE = 0.0058
------------------------------------------------------------------------------
D.lgdp | Coefficient Std. err. t P>|t| [95% conf. interval]
-------------+----------------------------------------------------------------
ADJ |
lgdp |
L1. | -.0983863 .0253141 -3.89 0.000 -.1482142 -.0485585
-------------+----------------------------------------------------------------
LR |
lpce |
--. | .9344572 .0053982 173.11 0.000 .9238315 .945083
-------------+----------------------------------------------------------------
SR |
lpce |
D1. | .7787532 .0413702 18.82 0.000 .6973209 .8601855
LD. | .1095733 .0344448 3.18 0.002 .0417727 .1773739
L2D. | .0499719 .0343978 1.45 0.147 -.0177361 .11768
|
_cons | .0992455 .0261926 3.79 0.000 .0476884 .1508026
------------------------------------------------------------------------------
note: estat btest has been superseded by estat ectest
as the prime procedure to test for a levels relationship.
(click to run)
Pesaran/Shin/Smith (2001) ARDL Bounds Test
H0: no levels relationship F = 7.553
t = -3.887
Critical Values (0.1-0.01), F-statistic, Case 3
| [I_0] [I_1] | [I_0] [I_1] | [I_0] [I_1] | [I_0] [I_1]
| L_1 L_1 | L_05 L_05 | L_025 L_025 | L_01 L_01
------+----------------+----------------+----------------+---------------
k_1 | 4.04 4.78 | 4.94 5.73 | 5.77 6.68 | 6.84 7.84
accept if F < critical value for I(0) regressors
reject if F > critical value for I(1) regressors
Critical Values (0.1-0.01), t-statistic, Case 3
| [I_0] [I_1] | [I_0] [I_1] | [I_0] [I_1] | [I_0] [I_1]
| L_1 L_1 | L_05 L_05 | L_025 L_025 | L_01 L_01
------+----------------+----------------+----------------+---------------
k_1 | -2.57 -2.91 | -2.86 -3.22 | -3.13 -3.50 | -3.43 -3.82
accept if t > critical value for I(0) regressors
reject if t < critical value for I(1) regressors
k: # of non-deterministic regressors in long-run relationship
Critical values from Pesaran/Shin/Smith (2001)
All three tabs reject: the \(F\)-statistic sits above the upper \(I(1)\) bound, so a levels relationship between log GDP and log consumption is supported without having to settle their integration orders first.
The selected lag orders differ slightly — R’s AIC search picks ARDL(1,2) while statsmodels and Stata pick ARDL(1,3) — which moves the \(F\)-statistic by about a tenth. That is worth seeing rather than hiding: the bounds test is a test on a selected model, and the selection is part of the procedure.
Two API traps in the ARDL tabs
Two API traps live in this slide.
ARDL::auto_ardl() returns a list, and the bounds tests want $best_model — handing over the list produces an error about applying vcov to a list.
statsmodels.tsa.ardl no longer exports bounds_f_test; the test is a method on the fitted UECM.
The same logic that motivated Zivot-Andrews for unit roots applies to cointegration. If the long-run relationship shifts once during the sample, a residual-based test computed on the unshifted regression sees residuals that fail to mean-revert, and concludes there is no cointegration.
Gregory & Hansen (1996) allow a regime shift at an unknown date \(\tau = \lambda T\). In the level-shift (C) model,
Three variants: C a shift in the intercept, C/T intercept and trend, C/S intercept and slope — the last allows \(\beta\) itself to change.
Because the statistic is an infimum over \(\lambda\), the critical values are more negative than the Engle-Granger ones. For the C model with one regressor, from Gregory & Hansen (1996) Table 1:
1%
5%
10%
\(ADF^*\) (model C)
\(-5.13\)
\(-4.61\)
\(-4.34\)
What a rejection does not say
Rejection says “cointegrated once a break is allowed”. It does not establish that a break occurred — the alternative bundles the two together.
And as with Zivot-Andrews, the break is under \(H_1\): the test is not valid if the true process has a break under the null.
Trimming is again 15% at each end, for the same reason.
# No CRAN package ships the test, and it is short enough to write directly:# for every candidate break, fit the shifted regression and ADF its residuals.greg_hansen <-function(y, x, trim =0.15, lags =4) { n <-length(y) lo <-floor(trim * n) hi <-ceiling((1- trim) * n) best <-list(stat =Inf, brk =NA_integer_)for (b in lo:hi) { du <-as.numeric(seq_len(n) > b) res <-residuals(lm(y ~ x + du)) st <-as.numeric(urca::ur.df(res, type ="none", lags = lags,selectlags ="AIC")@teststat[1])if (st < best$stat) best <-list(stat = st, brk = b) } best}gh <-greg_hansen(sim_df$y_c, sim_df$x_c)cat(sprintf("ADF* = %.4f at break observation %d\n", gh$stat, gh$brk))
Test Statistic CV_5pct Break Verdict
Engle-Granger (no break) -9.2803 -3.34 NA reject H0
Gregory-Hansen ADF* (model C) -9.6512 -4.61 222 reject H0
Break fraction lambda = 0.740 of the sample
Code
import numpy as np, pandas as pdimport statsmodels.api as smfrom statsmodels.tsa.stattools import adfullerdef greg_hansen(y, x, trim=0.15, lags=4): y = np.asarray(y, float); x = np.asarray(x, float); n = y.size lo, hi =int(np.floor(trim * n)), int(np.ceil((1- trim) * n)) best_stat, best_brk = np.inf, -1for b inrange(lo, hi +1): du = (np.arange(1, n +1) > b).astype(float) X = sm.add_constant(np.column_stack([x, du])) res = sm.OLS(y, X).fit().resid st = adfuller(res, maxlag=lags, autolag="AIC", regression="n")[0]if st < best_stat: best_stat, best_brk = st, breturn best_stat, best_brksim = pd.read_csv("../data/ur-vecm-sim.csv")stat, brk = greg_hansen(sim["y_c"], sim["x_c"])eg0 = adfuller(sm.OLS(sim["y_c"], sm.add_constant(sim["x_c"])).fit().resid, maxlag=4, autolag="AIC", regression="n")[0]lines = [f"{'Test':<32}{'Statistic':>12}{'5% CV':>9}{'Break':>8}",f"{'Engle-Granger (no break)':<32}{eg0:>12.4f}{-3.34:>9.2f}{'-':>8}",f"{'Gregory-Hansen ADF* (model C)':<32}{stat:>12.4f}{-4.61:>9.2f}{brk:>8d}",f"\nBreak fraction lambda = {brk/len(sim):.3f} of the sample"]out ="\n".join(lines)import sys; sys.stdout.write(out +"\n"); sys.stdout.flush()
Test Statistic 5% CV Break
Engle-Granger (no break) -11.0911 -3.34 -
Gregory-Hansen ADF* (model C) -11.4869 -4.61 219
Break fraction lambda = 0.730 of the sample
231
Code
quietly import delimited "../data/ur-vecm-sim.csv", clearquietlydestring_all, replacequietlytsset tquietly {local n = _Nlocal lo = floor(0.15*`n')local hi = ceil(0.85*`n')local best = .local brk = .capturedrop dugenbyte du = 0forvalues b = `lo'/`hi' {quietlyreplace du = (_n > `b')quietlyregress y_c x_c ducapturedrop ghresquietlypredict ghres, residualsquietlydfuller ghres, lags(4) noconstantif`best' == . | r(Zt) < `best' {local best = r(Zt)local brk = `b' } }}display"Gregory-Hansen ADF* (model C) = " %8.4f `best'" at break observation "`brk'display"5% critical value = -4.61"
Gregory-Hansen ADF* (model C) = -6.9152 at break observation 222
5% critical value = -4.61
On the simulated pair there is no break, and the honest reading is that the test still rejects — the relationship is so strongly cointegrated that no spurious break can hide it. The comparison with the plain Engle-Granger statistic is the useful part: allowing a break can only make the statistic more negative, since the no-break case is inside the search space, so the two must be read against different critical values.
That is exactly the trap this test exists to avoid in the other direction. On real data where Engle-Granger fails to reject, a Gregory-Hansen rejection changes the conclusion from “no long-run relationship” to “a long-run relationship that shifted once”.
Why the three searches agree closely, not exactly
The three implementations are grid searches over the same 211 candidate break dates, so they agree on the break to within one observation.
Where they can differ is the ADF lag chosen inside each iteration, which is why the statistics agree closely rather than exactly.
Which Cointegration Test
Test
Null
Variables
Finds the number of vectors
Method
Engle-Granger
No cointegration
2
No, assumes one
Residual ADF
Johansen trace
rank \(\le r\)
\(K\)
Yes
ML eigenvalues
Johansen max-eigen
rank \(= r\)
\(K\)
Yes
ML eigenvalues
ARDL bounds
No levels relation
2+, mixed order
No
\(F\)-test on lagged levels
Gregory-Hansen
No cointegration
2
No
Residual ADF with a break
Practical guidance:
Two variables, both clearly \(I(1)\) — Engle-Granger first, confirm with Johansen
Three or more variables — Johansen, always; multiple long-run relations are possible and only it will find them
Integration order uncertain or mixed — ARDL bounds
A visible level shift in the sample — Gregory-Hansen alongside Engle-Granger
Disagreement between tests — treat it as information about specification, not as noise to average away
Every test is a test of a specification
Every one of these tests is a test of a specification, not of a fact about the world. The rank, the deterministic terms and the lag order all enter the null being tested.
Variable | Obs Mean Std. dev. Min Max
-------------+---------------------------------------------------------
lgdp | 293 9.13165 .6318573 7.953386 10.09702
lpce | 293 8.683982 .6765716 7.423239 9.730202
gs10 | 293 5.523584 2.873639 .62 15.15
gs1 | 293 4.615051 3.229879 .06 15.72
Time variable: qdate, 1953q2 to 2026q2
Delta: 1 quarter
Four series, two pairs, two different reasons to expect a long-run relationship.
log GDP and log consumption trend together over seventy years. The permanent income hypothesis says the ratio should be stationary — that is a cointegration restriction, and it is tested in Part V.
The two Treasury yields wander over the same range without a common trend in levels, but the spread between them is what the expectations hypothesis restricts. They are the natural candidate for a cointegrating vector of \([1,-1]\).
Both pairs look non-stationary and neither looks like it is drifting apart permanently — which is exactly the configuration in which formal testing is needed, because the eye cannot separate “cointegrated” from “two independent walks that happen to move together”.
Why the CSV headers are lower case
The CSV stores its column names in lower case. Stata’s import delimited lower-cases variable names on the way in, so a header of GS10 would become gs10 there while R and Python kept GS10.
Writing lower case at source is what keeps the three tabs referring to the same variable.
=== lgdp ===
ADF = -1.5517
PP = -1.1205
KPSS test for lgdp
Maxlag = 5 chosen by Schwert criterion
Autocovariances weighted by Bartlett kernel
Critical values for H0: lgdp is trend stationary
10%: 0.119 5% : 0.146 2.5%: 0.176 1% : 0.216
Lag order Test statistic
0 5.66
1 2.85
2 1.92
3 1.45
4 1.17
5 .983
=== lpce ===
ADF = -1.4395
PP = -1.1338
KPSS test for lpce
Maxlag = 5 chosen by Schwert criterion
Autocovariances weighted by Bartlett kernel
Critical values for H0: lpce is trend stationary
10%: 0.119 5% : 0.146 2.5%: 0.176 1% : 0.216
Lag order Test statistic
0 5.58
1 2.82
2 1.89
3 1.43
4 1.15
5 .968
=== gs10 ===
ADF = -2.2150
PP = -2.0139
KPSS test for gs10
Maxlag = 5 chosen by Schwert criterion
Autocovariances weighted by Bartlett kernel
Critical values for H0: gs10 is trend stationary
10%: 0.119 5% : 0.146 2.5%: 0.176 1% : 0.216
Lag order Test statistic
0 5.39
1 2.72
2 1.83
3 1.39
4 1.12
5 .94
=== gs1 ===
ADF = -3.0202
PP = -2.5613
KPSS test for gs1
Maxlag = 5 chosen by Schwert criterion
Autocovariances weighted by Bartlett kernel
Critical values for H0: gs1 is trend stationary
10%: 0.119 5% : 0.146 2.5%: 0.176 1% : 0.216
Lag order Test statistic
0 4.14
1 2.1
2 1.42
3 1.08
4 .877
5 .742
All four series behave the same way, and the way we want: ADF and PP fail to reject the unit root, KPSS rejects stationarity, and the Ng-Perron \(MZ_t\) sits well above its critical value of \(-2.91\). Four tests with two different nulls all pointing the same direction is about as clean as macro data gets.
This is the confirmatory strategy of Part II doing its job. Had ADF failed to reject while KPSS also failed to reject, the honest conclusion would have been that the data cannot tell — not that the series is \(I(1)\).
Having established that all four are \(I(1)\), cointegration testing is now meaningful and Part V can proceed.
The four-variable system used from here to the end of the deck, from Johansen (1988) and Johansen & Juselius (1990): Finnish money demand, 106 quarterly observations from 1958Q2 to 1984Q3.
Variable
Meaning
lrm1
log real money, M3
lny
log real income
lnmr
log nominal interest rate
difp
change in the log price level, i.e. inflation
Money demand theory predicts one long-run relation among the four:
with \(\eta > 0\) from the transactions motive, and \(\theta, \phi > 0\) because both the interest rate and inflation are costs of holding money. One relation among four variables means \(r = 1\) and \(K - r = 3\) common stochastic trends — a testable prediction, not an assumption.
Code
fin_df <-read.csv("../data/ur-vecm-finland.csv")fin_df <- fin_df |>mutate(date =seq(as.Date("1958-04-01"), by ="quarter", length.out =n()))p1 <-ggplot(fin_df) +aes(date, lrm1) +geom_line(colour ="#185FA5", linewidth =0.9) +labs(title ="log real money", x =NULL, y =NULL)p2 <-ggplot(fin_df) +aes(date, lny) +geom_line(colour ="#1D9E75", linewidth =0.9) +labs(title ="log real income", x =NULL, y =NULL)p3 <-ggplot(fin_df) +aes(date, lnmr) +geom_line(colour ="#D85A30", linewidth =0.9) +labs(title ="log nominal rate", x =NULL, y =NULL)p4 <-ggplot(fin_df) +aes(date, difp) +geom_line(colour ="#BA7517", linewidth =0.9) +labs(title ="inflation", x =NULL, y =NULL)(p1 | p2) / (p3 | p4)
The three log-level series each move across a wide range with no tendency to return — the visual signature of \(I(1)\). Inflation is different: it oscillates around a small positive mean, spikes with the 1970s oil shocks, and comes back. It is the one variable of the four that might be \(I(0)\).
That asymmetry has a practical consequence for the next slide. The three levels should be tested with a trend in the ADF specification; difp should not, because fitting a trend to a series that has none costs power for nothing.
import pandas as pd, warningswarnings.filterwarnings("ignore")from statsmodels.tsa.stattools import adfuller, kpssfin = pd.read_csv("../data/ur-vecm-finland.csv")lines = [f"{'Variable':<10}{'ADF lev':>10}{'p':>8}{'ADF diff':>10}{'p':>8}{'order':>9}"]for col in fin.columns: lev = adfuller(fin[col], maxlag=4, autolag="AIC", regression="ct") dif = adfuller(fin[col].diff().dropna(), maxlag=4, autolag="AIC", regression="c") order ="I(1)"if lev[1] >0.05and dif[1] <0.05else"check" lines.append(f"{col:<10}{lev[0]:>10.3f}{lev[1]:>8.3f}"f"{dif[0]:>10.3f}{dif[1]:>8.3f}{order:>9}")lines.append("\nKPSS, H0 is stationarity:")for col in fin.columns: stat, pval, *_ = kpss(fin[col], regression="c", nlags="auto") lines.append(f" {col:<8} stat = {stat:.3f} p = {pval:.3f}")out ="\n".join(lines)import sys; sys.stdout.write(out +"\n"); sys.stdout.flush()
Variable ADF lev p ADF diff p order
lrm1 -2.546 0.305 -5.040 0.000 I(1)
lny -1.565 0.806 -4.441 0.000 I(1)
lnmr -4.874 0.000 -7.208 0.000 check
difp -2.755 0.214 -10.297 0.000 I(1)
KPSS, H0 is stationarity:
lrm1 stat = 1.555 p = 0.010
lny stat = 1.579 p = 0.010
lnmr stat = 0.091 p = 0.100
difp stat = 0.783 p = 0.010
447
Each level fails to reject, each first difference rejects. That pair of results, in that order, is what licenses treating the system as \(I(1)\) and moving on to Johansen.
difp is the one to watch. It is already a first difference of a price level, so if it were \(I(1)\) the price level would be \(I(2)\) and the whole system would need re-specifying. The tests put it at \(I(1)\) on this sample at the 5% level, but it is the marginal case, and Johansen’s rank result should be checked for sensitivity to dropping it.
Pre-testing errors compound
Testing four variables at 5% each is four chances to be wrong. Unit root pre-testing feeds its errors forward into the rank test and then into the VECM.
That is a large part of why the ARDL bounds test, which needs no pre-testing, exists at all.
fin_df <-read.csv("../data/ur-vecm-finland.csv")# K = 2 lags in LEVELS, so the VECM has one lag in differencesjo_trace <- urca::ca.jo(fin_df[, c("lrm1", "lny", "lnmr", "difp")],type ="trace", ecdet ="none", K =2,spec ="longrun")summary(jo_trace)# The cointegrating vector, normalised on lrm1cajorls(jo_trace, r =1)$beta
Hypothesis Trace Trace_5pct MaxEigen MaxE_5pct
r = 0 | 79.209 48.28 39.942 27.14
r <= 1 | 39.267 31.52 29.230 21.07
r <= 2 | 10.037 17.95 7.787 14.90
r <= 3 | 2.251 8.18 2.251 8.18
import numpy as np, pandas as pd, warningswarnings.filterwarnings("ignore")from statsmodels.tsa.vector_ar.vecm import VECM, select_coint_rankfin = pd.read_csv("../data/ur-vecm-finland.csv")sel = select_coint_rank(fin, det_order=0, k_ar_diff=1, method="trace", signif=0.05)# Impose r = 1, the rank selected by the R and Stata trace tests. See the# reading note: statsmodels applies a different deterministic convention and# selects a higher rank on the same data.res = VECM(fin, k_ar_diff=1, coint_rank=1, deterministic="co").fit()lines = [f"statsmodels trace test selects r = {sel.rank}","imposing r = 1 to match the R and Stata specification","","Cointegrating vector beta, normalised on lrm1:"," "+" ".join(f"{v:>9.4f}"for v in res.beta.ravel()),"","Loading coefficients alpha:"," "+" ".join(f"{v:>9.4f}"for v in res.alpha.ravel())]out ="\n".join(lines)import sys; sys.stdout.write(out +"\n"); sys.stdout.flush()
statsmodels trace test selects r = 2
imposing r = 1 to match the R and Stata specification
Cointegrating vector beta, normalised on lrm1:
1.0000 -1.1172 -4.6829 5.4674
Loading coefficients alpha:
0.0569 0.0628 0.1066 -0.0029
258
Code
quietly import delimited "../data/ur-vecm-finland.csv", clearquietlygen t = _nquietlytsset tvecrank lrm1 lny lnmr difp, trend(constant) lags(2) maxquietlyvec lrm1 lny lnmr difp, trend(constant) lags(2) rank(1)display""display"Cointegrating vector, normalised on lrm1:"matrix b = e(beta)matrixlist b
The trace sequence rejects \(r = 0\) decisively and then fails to reject \(r \le 1\), so \(\hat r = 1\): one long-run relationship among the four variables and three common stochastic trends. That is what money demand theory predicts, and it is not something the estimation was told to find.
The normalised vector is the same in all three languages to four decimals:
An income elasticity near 1.1 is plausible for money demand. The interest-rate and inflation coefficients carry the signs the theory expects once the normalisation convention is unwound.
When statsmodels picks a different rank
statsmodels selects a higher rank on this data — 2 or 3 depending on det_order. This is not a bug in either package.
The Johansen critical values depend on which deterministic terms are restricted to the cointegrating space, and the three programs do not share a default.
Fix the specification explicitly, in every language, and report it. The estimates conditional on \(r = 1\) then agree exactly.
If \(y_t\) and \(x_t\) are \(I(1)\) and \(y_t - \beta x_t \sim I(0)\), the Granger representation theorem guarantees that at least one of the two adjusts:
The sign is the whole diagnostic. \(\alpha\) must be negative — a positive deviation from equilibrium has to pull \(\Delta y\) down, or the system is explosive rather than error-correcting.
\(\lvert\alpha\rvert\) is the fraction of a disequilibrium removed each period, which converts directly into a half-life:
Because \(\hat u_{t-1}\) is stationary, the \(t\)-statistic on \(\alpha\) has its usual distribution — the non-standard asymptotics live entirely in the first step.
This is one of the few places in this deck where a conventional \(t\)-test is legitimate.
The known truth is available here too. The DGP made the equilibrium error an AR(1) with coefficient 0.4, so \(u_t - u_{t-1} = -0.6\,u_{t-1} + \eta_t\) and the correct \(\alpha\) is \(-0.6\). The estimate lands close to it in all three languages, with a half-life of roughly one period.
Notice what the ECM has bought. The dependent variable is a difference, so it is \(I(0)\) and standard inference applies — but the equation still contains the long-run relationship, in levels, through \(\hat u_{t-1}\). Nothing has been thrown away by differencing, which is exactly what a plain VAR in differences would have done.
The Engle-Granger first stage estimates \(\beta\) consistently, and even superconsistently — but its standard error is wrong, so a confidence interval built from it has no coverage guarantee. The culprit is the correlation between \(\Delta x_t\) and \(u_t\) that cointegration itself induces.
Stock & Watson (1993) fix it by adding leads and lags of \(\Delta x\) to the static regression:
A rule of thumb for \(q\): 1 or 2 for quarterly data, more when \(T > 200\).
Report the DOLS estimate with its standard error, and use OLS only to generate residuals for the second step.
If DOLS and OLS differ substantially, that difference is itself information: the endogeneity the leads and lags are correcting for is large in this sample.
FMOLS, the other standard fix
Fully modified OLS (Phillips-Hansen) is the other standard fix and targets the same problem non-parametrically.
DOLS is easier to explain and to implement identically in three languages, which is why it is the one shown here.
The two estimates of the long-run income elasticity of money demand sit close to each other, which is reassuring: on this sample the endogeneity correction does not move the point estimate much. The standard errors are what change, and only the DOLS one supports a confidence interval.
The elasticity above unity is the standard finding for this dataset. Money holdings rise slightly more than proportionally with real income over the long run.
DOLS and Johansen answer different questions
The bivariate DOLS here and the four-variable Johansen system on the next slides answer different questions. DOLS estimates one long-run coefficient having assumed a single relation exists; Johansen estimates how many relations there are and what they look like, jointly.
Where both are valid they should broadly agree — and here the income coefficient from the four-variable system, 1.1172, is in the same territory.
Everything on the right is stationary: \(\Delta\mathbf{y}\) by construction, and \(\boldsymbol\beta'\mathbf{y}_{t-1}\) because that is what cointegration means. This is why the VECM has conventional inference where the levels VAR does not.
Three routes in R, and they are not interchangeable:
Route
Gives
Use when
urca::cajorls(jo, r)
Restricted system as lm objects
You want coeftest or robust standard errors
tsDyn::VECM(data, lag, r)
Standalone ML or two-step OLS fit
You will extend to TVECM
vars::vec2var(jo, r)
A varest object
You need IRF, FEVD or diagnostics
With \(r = 1\) and \(K = 4\), \(\boldsymbol\alpha\) is a single column and each element says how that equation responds to last period’s disequilibrium:
\(\alpha_i \ne 0\) — variable \(i\) responds to the disequilibrium and helps restore it
\(\alpha_i = 0\) — variable \(i\) is weakly exogenous for the long-run parameters; it pushes the system but is not pushed by it
Which elements are zero is an economic claim, and it is testable. Money demand theory says income should be weakly exogenous: the money market does not drive national income. That test is two slides ahead.
K counts levels, lag counts differences
ca.jo(K = 2) and VECM(lag = 1) describe the same model. \(K\) counts lags in levels, lag counts lags in differences, and they differ by one.
Getting this wrong changes the estimates without producing any error.
fin_df <-read.csv("../data/ur-vecm-finland.csv")Yf <- fin_df[, c("lrm1", "lny", "lnmr", "difp")]jo_trace <- urca::ca.jo(Yf, type ="trace", ecdet ="none", K =2,spec ="longrun")# Route 1: restricted least squares from the Johansen fitvecm_ur <- urca::cajorls(jo_trace, r =1)vecm_ur$betavecm_ur$rlm$coefficients["ect1", ]# Route 2: tsDyn, note lag = K - 1vecm_ts <- tsDyn::VECM(Yf, lag =1, r =1, estim ="ML", include ="const")summary(vecm_ts)
That agreement is worth pausing on, because nothing else in this deck matches this cleanly. The Johansen procedure is a solved eigenvalue problem with no tuning parameters once the rank, lag order and deterministic specification are fixed — so once those three are pinned down, the three implementations have nothing left to disagree about.
The last loading, on inflation, is essentially zero. That is the first hint of weak exogeneity, and the next slide tests it properly.
estat cointegration does not exist
estat cointegration is not a Stata command and never was; the cointegrating vector is already in the vec output and in e(beta).
Reaching for a postestimation command that does not exist is a good way to lose a whole chunk’s output, because Stata errors print as ordinary text rather than as a failed cell.
The roots check is the one with a twist. A cointegrated system of rank \(r\) has exactly \(K - r\)unit roots by construction — they are the common stochastic trends. Seeing moduli equal to 1 is correct here; what would signal a problem is a modulus above 1, or more unit roots than \(K - r\).
Code
vec_var <- vars::vec2var(jo_trace, r =1)vars::serial.test(vec_var, lags.pt =16, type ="PT.asymptotic")vars::arch.test(vec_var, lags.multi =5)vars::roots(vec_var)
Portmanteau test for serial correlation: stat = 392.424, p = 0.0000
Read vecstable and vars::roots() the same way. With \(K = 4\) and \(r = 1\) there should be exactly three moduli equal to 1 — the three common stochastic trends the rank test already told us about — and everything else strictly inside the unit circle. That is what all three tabs report, so the rank selection and the stability check corroborate each other.
The serial correlation and ARCH tests are about whether the lag order is adequate. A rejection there usually means adding a lag rather than abandoning the specification.
Which diagnostic failure actually matters
Non-normal residuals are common in macro data and are not fatal: the Johansen estimator remains consistent, and the asymptotic distribution of the rank statistics does not depend on normality.
Serial correlation is the serious one, because it means the model is misspecified.
Both are linear restrictions with likelihood ratio tests, and both are answering economic questions rather than statistical ones.
Weak exogeneity — a restriction on \(\boldsymbol\alpha\). Variable \(i\) is weakly exogenous for the long-run parameters if it does not respond to the disequilibrium:
\[
H_0: \alpha_i = 0
\]
Written as \(\boldsymbol\alpha = \mathbf{A}\boldsymbol\psi\) for a known \(K\times(K-1)\) matrix \(\mathbf{A}\) that deletes row \(i\). Under \(H_0\) the statistic is \(\chi^2(r)\).
Structural restrictions — a restriction on \(\boldsymbol\beta\). Write \(\boldsymbol\beta = \mathbf{H}\boldsymbol\varphi\) where \(\mathbf{H}\) encodes the hypothesis. Excluding a variable from the long-run relation, or imposing a unit elasticity, both take this form. The statistic is \(\chi^2\) with degrees of freedom equal to the number of restrictions times \(r\).
Weak exogeneity is not a technicality. If income is weakly exogenous in a money-demand system, then
conditioning on income loses no information about the long-run parameters, so a single-equation ECM is efficient and the full system is unnecessary
causality in the long run runs from income to money and not back
forecasting income does not require modelling the money market
The test is therefore the formal version of the question “which of these variables is doing the adjusting?” — and its answer determines whether the four-equation system was needed at all.
Code
# alrtest: is variable i weakly exogenous? A deletes the i-th row of alpha.A_lny <-matrix(c(1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1), nrow =4, byrow =TRUE)[, -2]alrtest(jo_trace, A = A_lny, r =1)# blrtest: can lnmr be excluded from the long-run relation?H_excl <-matrix(c(1,0,0,0,1,0,0,0,0,0,0,1), nrow =4, byrow =TRUE)blrtest(jo_trace, H = H_excl, r =1)
Excluding lnmr from the long run: LR = 10.6526, df = 1, p = 0.0011
Code
import numpy as np, pandas as pd, warningswarnings.filterwarnings("ignore")from scipy import statsfrom statsmodels.tsa.vector_ar.vecm import VECMfin = pd.read_csv("../data/ur-vecm-finland.csv")res = VECM(fin, k_ar_diff=1, coint_rank=1, deterministic="co").fit()# statsmodels reports a standard error for each loading, so weak exogeneity# can be read off directly as a t-test on alpha_i = 0.alpha = res.alpha.ravel()se = res.stderr_alpha.ravel()lines = ["Weak exogeneity, H0: alpha_i = 0",f"{'Variable':<10}{'alpha':>10}{'SE':>10}{'t':>9}{'p':>9}{'exog':>7}"]for c, a, s inzip(fin.columns, alpha, se): t = a / s p =2* (1- stats.norm.cdf(abs(t))) lines.append(f"{c:<10}{a:>10.4f}{s:>10.4f}{t:>9.3f}{p:>9.4f}"f"{('yes'if p >0.05else'no'):>7}")out ="\n".join(lines)import sys; sys.stdout.write(out +"\n"); sys.stdout.flush()
Weak exogeneity, H0: alpha_i = 0
Variable alpha SE t p exog
lrm1 0.0569 0.0272 2.095 0.0362 no
lny 0.0628 0.0206 3.047 0.0023 no
lnmr 0.1066 0.0162 6.566 0.0000 no
difp -0.0029 0.0059 -0.487 0.6262 yes
313
Code
quietly import delimited "../data/ur-vecm-finland.csv", clearquietlygen t = _nquietlytsset tquietlyvec lrm1 lny lnmr difp, trend(constant) lags(2) rank(1)display"Weak exogeneity: Wald tests on the loading coefficients"foreach v in lrm1 lny lnmr difp {quietlytest [D_`v']L._ce1 = 0display" `v'" _col(10) "chi2(1) = " %8.4f r(chi2) " p = " %6.4f r(p)}
Weak exogeneity: Wald tests on the loading coefficients
lrm1 chi2(1) = 4.1344 p = 0.0420
lny chi2(1) = 8.7511 p = 0.0031
lnmr chi2(1) = 40.6262 p = 0.0000
difp chi2(1) = 0.2236 p = 0.6363
The interesting result is what is not rejected. Of the four loadings, only inflation’s cannot be distinguished from zero — so difp is weakly exogenous for the long-run parameters, while income and the interest rate both respond to the disequilibrium and clearly do not drop out.
The exclusion test on the interest rate asks a different question: whether lnmr belongs in the long-run relation at all. Rejecting means the opportunity cost of holding money is part of the equilibrium, which is what money demand theory requires.
Three routes to the same hypothesis
The three tabs test the same hypothesis by different routes — a likelihood ratio test in R, an asymptotic \(t\)-test on the loading in Python, a Wald test in Stata.
They are asymptotically equivalent and will not give identical numbers. Where they disagree materially in a small sample, the likelihood ratio version is the one to trust.
The permanent income hypothesis implies that consumption and income share a common stochastic trend: transitory income shocks are smoothed away, permanent ones are consumed. The testable content is that \(\log C_t - \log Y_t\) is stationary — a cointegrating vector of \([1, -1]\).
Part IV established that both series are \(I(1)\). This slide asks whether they are cointegrated, estimates the vector, and checks whether it is close to \([1,-1]\).
Code
fred_df <-read.csv("../data/ur-vecm-fred.csv")Y_us <-as.matrix(fred_df[, c("lgdp", "lpce")])joh_us <- urca::ca.jo(Y_us, type ="trace", ecdet ="const", K =3,spec ="longrun")summary(joh_us)vecm_us <- urca::cajorls(joh_us, r =1)vecm_us$betavecm_us$rlm$coefficients["ect1", ]
Hypothesis Trace cv_5pct
r = 0 | 85.012 19.96
r <= 1 | 12.380 9.24
The trace test rejects \(r=0\) and stops at \(\hat r = 1\): log GDP and log consumption are cointegrated over this sample, which is the first thing the permanent income hypothesis requires.
The second requirement is sharper. PIH implies the vector is \([1,-1]\) — a constant long-run consumption share. The estimate is close to but not exactly \(-1\), and whether the difference is statistically meaningful is a \(\chi^2(1)\) restriction test of exactly the kind the previous slide ran on the Finland system. That is left as an exercise, and it is a real one: the answer over a seventy-year sample that includes a secular decline in the saving rate is not obvious.
Cointegration is a statement about the sample
Cointegration is a statement about the sample period. A relationship that holds from 1953 to 2026 may hide a shift in the middle — which is precisely what the Gregory-Hansen test of Part III is for.
Running it on this pair is the natural follow-up.
Part VI — Impulse Responses, FEVD and Structure
ἥκω γὰρ ἐς γῆν, φησί, καὶ κατέρχομαι·
I have come to this land, he says, and I am coming back
In a stationary VAR every impulse response decays to zero. In a VECM it does not, and that is the entire reason to use one.
The system has \(K - r\)permanent shocks — innovations to the common stochastic trends, whose effect on the level never dies — and \(r\)transitory shocks, absorbed by the error correction mechanism.
with \(\tilde{\mathbf{y}}_t\) the stationary part and \(\operatorname{rank}(\boldsymbol\Xi) = K - r\). The impulse response of a level variable therefore settles at a plateau, not at zero, and the height of that plateau is the permanent component of the shock.
\[
\omega_{jk,h} = \frac{\text{contribution of shock } k \text{ to the forecast error variance of } j \text{ at horizon } h}{\text{total forecast error variance of } j \text{ at horizon } h}
\]
As \(h\) grows, permanent shocks take an increasing share, because the transitory ones stop contributing once they have died out. A variable whose long-horizon FEVD is dominated by other variables’ shocks is one that is being pulled by the common trends rather than driving them — the FEVD counterpart of a large loading \(\alpha\).
Warning
Do not report confidence bands for cumulative IRFs from a VAR estimated in levels. The long-run impact matrix
is a non-linear function of a sum that converges at the super-consistent rate \(O_p(T^{-1})\) to a non-standard, biased limit. Conventional intervals for the long-horizon cumulative response have no coverage guarantee.
The VECM representation is the fix: it separates the \(I(1)\) and \(I(0)\) directions explicitly, and the Granger representation theorem is what licenses ordinary bootstrap inference on the responses.
Cholesky ordering still matters for orthogonalised responses, exactly as in a stationary VAR. Ordering lny before lrm1 says income does not respond to money within the quarter.
The response settles at a small non-zero value instead of returning to zero, and the cumulative response keeps growing. That is the cointegration showing up in the impulse response: the permanent component of an income shock transmits permanently to the level of real money.
This is the concrete difference from a VAR in first differences. Differencing would have forced every level response to die out, imposing by assumption exactly the thing the rank test rejected.
Why only the R tab has a band
The R tab bootstraps 300 replications for the band; the Python tab plots the point response from the orthogonalised MA representation without a band, since statsmodels does not bootstrap VECM responses.
At h = 20 the income shock explains 3.4% of the variance of lrm1.
Code
import numpy as np, pandas as pd, warningswarnings.filterwarnings("ignore")from statsmodels.tsa.vector_ar.vecm import VECMfin = pd.read_csv("../data/ur-vecm-finland.csv")cols = fin.columns.tolist()res = VECM(fin, k_ar_diff=1, coint_rank=1, deterministic="co").fit()# VECMResults has no fevd method, so build it from the orthogonalised MA# coefficients: the share of shock k in the variance of j at horizon h is the# cumulated squared response divided by the total.theta = res.orth_ma_rep(maxn=20)cum = np.cumsum(theta **2, axis=0)fevd = cum / cum.sum(axis=2, keepdims=True)i_r = cols.index("lrm1")lines = ["Forecast error variance of lrm1, share attributed to each shock:",f"{'Horizon':>8}"+"".join(f"{c:>10}"for c in cols)]for h in (1, 4, 8, 20): row = fevd[h -1, i_r, :] lines.append(f"{h:>8}"+"".join(f"{v:>10.4f}"for v in row))lines.append(f"\nAt h = 20 the income shock explains "f"{100*fevd[19, i_r, cols.index('lny')]:.1f}% of the variance of lrm1.")out ="\n".join(lines)import sys; sys.stdout.write(out +"\n"); sys.stdout.flush()
Forecast error variance of lrm1, share attributed to each shock:
Horizon lrm1 lny lnmr difp
1 1.0000 0.0000 0.0000 0.0000
4 0.9468 0.0253 0.0263 0.0017
8 0.9204 0.0304 0.0468 0.0024
20 0.9055 0.0340 0.0580 0.0025
At h = 20 the income shock explains 3.4% of the variance of lrm1.
377
All three tabs put the share of the forecast error variance of real money attributable to an income shock at about 3.4% at a twenty-quarter horizon — R’s vars::fevd, the hand-built decomposition in Python, and Stata’s irf table fevd agree to three decimals.
The reason to build it by hand in Python is that VECMResults has no fevd method. The definition is short enough that this is a feature rather than a workaround: the FEVD is the cumulated squared orthogonalised responses, normalised, and writing that down is clearer than calling something opaque.
Real money is overwhelmingly driven by its own shocks even at long horizons. The income share grows with the horizon, which is the permanent component asserting itself, but slowly — consistent with the small loading coefficients estimated in Part V.
The reduced-form VECM leaves the shocks correlated. A structural VECM imposes enough restrictions to give them an economic interpretation, and the natural restriction in a cointegrated system is the one cointegration itself supplies:
\(K - r\) shocks are permanent — they move the common stochastic trends
\(r\) shocks are transitory — they must have zero long-run effect on every variable
That is \(Kr\) long-run restrictions, free of charge, from the rank result. Together with the \(K(K-1)/2\) normalisations of a Cholesky-type scheme the system is exactly identified.
where \(\boldsymbol\alpha_\perp\) and \(\boldsymbol\beta_\perp\) are orthogonal complements and \(\boldsymbol\Gamma = \mathbf{I} - \sum_j\boldsymbol\Gamma_j\). Its rank is \(K - r\) by construction — which is also the cheapest numerical check that the whole specification is coherent.
Important
The permanent-transitory split is not a free lunch. It identifies how many shocks are permanent, and it pins down the space they live in — but which economic shock is which still requires an ordering or a further restriction, exactly as in a structural VAR.
Cointegration buys the number of permanent shocks. It does not buy their names.
Software. R has vars::SVAR for structural VARs and vars::BQ for the Blanchard-Quah decomposition, both of which want a varest object rather than the vec2var produced here. Python has no VECM structural routine. Stata has svar for VARs but no structural VECM command at all.
This is a real gap in all three, not an oversight in the deck. The practical route, shown next, is to compute \(\boldsymbol\Xi\) directly from the estimated \(\boldsymbol\alpha\), \(\boldsymbol\beta\) and \(\boldsymbol\Gamma\), which is a few lines of linear algebra and works identically everywhere.
* Stata has no structural VECM command: `svar` operates on `var`, noton `vec`,* and `irf graph sfevd` after `vec` fails because no structural model exists.* What Stata does give directly is the reduced-form decomposition, plus the* rank result that supplies the permanent/transitory count.quietly import delimited "../data/ur-vecm-finland.csv", clearquietlygen t = _nquietlytsset tquietlyvec lrm1 lny lnmr difp, trend(constant) lags(2) rank(1)display"K = 4 variables, r = 1 cointegrating vector"display" permanent shocks (K - r) = 3"display" transitory shocks (r) = 1"display""display"Reduced-form loading matrix alpha:"matrix a = e(alpha)matrixlist a
K = 4 variables, r = 1 cointegrating vector
permanent shocks (K - r) = 3
transitory shocks (r) = 1
Reduced-form loading matrix alpha:
a[1,4]
D_lrm1: D_lny: D_lnmr: D_difp:
L. L. L. L.
_ce1 _ce1 _ce1 _ce1
alpha .05693229 .06277693 .10659429 -.00285495
The number that matters is the rank: \(\operatorname{rank}(\boldsymbol\Xi) = 3 = K - r\) in both R and Python. Three permanent shocks drive the four variables in the long run, and one transitory shock is absorbed by the single cointegrating relation. That is a property of the estimated model, and it is worth checking because it fails immediately if the rank, lag order or deterministic specification are inconsistent with one another.
The entries of \(\boldsymbol\Xi\) agree to four decimals across R and Python, which is not guaranteed: \(\boldsymbol\alpha_\perp\) and \(\boldsymbol\beta_\perp\) are only defined up to a rotation, and MASS::Null and scipy.linalg.null_space could easily have picked different bases. They happen to pick the same one here. What the theory pins down is the column space, so any economic reading of an individual column still needs a further identifying restriction — the agreement is a convenience, not a licence.
How a broken Stata tab survives a clean render
The Stata tab states a gap rather than filling it. irf graph sfevd after vec produces sfevd not found — the IRF file simply has no structural statistics in it, because vec never estimated a structural model.
The error prints as ordinary output rather than failing the chunk, which is how a broken Stata tab can survive unnoticed in a deck that otherwise renders cleanly.
\(\gamma\) is the threshold in the error correction term, estimated by grid search over the observed values
\(d\) is the delay of the threshold variable
\(\boldsymbol\alpha^{(1)} \ne \boldsymbol\alpha^{(2)}\) is the whole point: regime-specific adjustment speeds
A three-regime version adds a band of inaction\([\gamma_1,\gamma_2]\) inside which \(\boldsymbol\alpha \approx 0\) and nothing adjusts at all.
Tip
The economics is always some form of fixed cost of adjusting. Correction happens only once the disequilibrium is large enough to be worth acting on.
Price transmission — retail prices follow wholesale prices down more slowly than up
Purchasing power parity — large deviations attract arbitrage, small ones persist inside the transaction-cost band
Commodity and futures markets — basis correction depends on the sign of the spread
Term structure — the spread between short and long rates reverts faster when it is unusually wide
The running example here is the last one: US three-month and six-month Treasury bill rates, where the expectations hypothesis predicts a cointegrating vector of \([1,-1]\) and arbitrage should bite harder when the spread is wide.
Testing before imposing. The Hansen-Seo bootstrap test has \(H_0\): linear VECM against \(H_1\): threshold VECM. Do not fit a TVECM without it — a grid search over thresholds will always find some improvement in fit.
intq_df <-read.csv("../data/ur-vecm-intqrt.csv")# nthresh = 1 gives two regimes; trim keeps at least 10% of the sample in eachtv <- tsDyn::TVECM(intq_df, nthresh =1, lag =1, trim =0.10,ngridTh =300, include ="const")summary(tv)gamma_hat <- tv$model.specific$Threshtv$coefficients$Bdown[, "ECT", drop =FALSE] # regime 1tv$coefficients$Bup[, "ECT", drop =FALSE] # regime 2
987 (6.6%) points of the grid lead to regimes with percentage of observations < trim and were not computed
The threshold splits the sample by the size of the term spread, and the two adjustment speeds are not the same. The regime where the spread is wide shows the faster correction — the reading the expectations hypothesis predicts, because a wide spread is what makes the arbitrage worth executing.
The R tab uses tsDyn::TVECM, which searches a finer grid and estimates the full system; the Python and Stata tabs implement the single-equation version of the same search directly. They locate the threshold in the same region without matching to the decimal, because the objective is a step function of \(\gamma\) and the three grids do not share their points.
trim does real work
trim does real work. At trim = 0.05 one regime can end up with a twentieth of the sample and an adjustment coefficient estimated off almost nothing.
Use 0.10 as a floor, and always report the regime sizes alongside the coefficients — a spectacular \(\hat\alpha^{(2)}\) estimated on eight observations is not a finding.
A linear VECM imposes one \(\alpha\) regardless of the sign of the disequilibrium. There are two standard ways to relax that, and they relax different things.
Asymmetric adjustment — Enders & Siklos (2001). Let the speed depend on the sign of the error:
with \(\rho_t = \mathbf{1}(ECT_{t-1} \ge 0)\). The momentum variant replaces the indicator with \(\mathbf{1}(\Delta ECT_{t-1} \ge 0)\), so what matters is whether the gap is widening or closing.
Asymmetry in the adjustment speed and asymmetry in the long-run relationship are different claims, and one does not imply the other. A market can correct upward deviations faster than downward ones while the long-run elasticity is perfectly symmetric — and vice versa. Test each separately, and be explicit about which one is being claimed.
Before reaching for either, check that the linear model actually fails:
Does theory predict asymmetry — downward nominal rigidity, menu costs, collusive pricing?
Does the plot of the ECT show deviations persisting more in one direction?
Does a Wald test on \(\alpha^{+} = \alpha^{-}\) or \(\beta^{+} = \beta^{-}\) reject?
NARDL inherits the ARDL bounds machinery, so it needs no pre-testing of integration orders — which is a large part of its popularity.
fin_df <-read.csv("../data/ur-vecm-finland.csv")# Partial sum decomposition of the income seriesd_lny <-diff(fin_df$lny)lny_pos <-c(0, cumsum(pmax(d_lny, 0)))lny_neg <-c(0, cumsum(pmin(d_lny, 0)))nardl_df <-data.frame(lrm1 = fin_df$lrm1,lny_pos = lny_pos, lny_neg = lny_neg)nardl_sel <- ARDL::auto_ardl(lrm1 ~ lny_pos + lny_neg, data = nardl_df,max_order =c(4, 4, 4), selection ="AIC")nardl_fit <- nardl_sel$best_modelARDL::bounds_f_test(nardl_fit, case =3)ARDL::multipliers(nardl_fit)
Selected order: NARDL(1,1,1)
Bounds F-test: F = 4.7674 p = 0.0524
Case 3, k = 2, 5% critical values: I(0) 4.19 I(1) 5.06
Long-run multipliers:
Term Estimate Std. Error t value Pr(>|t|)
(Intercept) 2.7750 0.0584 47.4904 0.0000
lny_pos 0.7421 0.1402 5.2953 0.0000
lny_neg 0.6415 0.2094 3.0641 0.0028
Code
import numpy as np, pandas as pdfrom statsmodels.regression.linear_model import OLSfrom scipy.stats import f as f_distfin = pd.read_csv("../data/ur-vecm-finland.csv")y, x = fin["lrm1"].values, fin["lny"].valuesT_obs =len(y)dx = np.diff(x)x_pos = np.concatenate([[0], np.cumsum(np.maximum(dx, 0))])x_neg = np.concatenate([[0], np.cumsum(np.minimum(dx, 0))])X = np.column_stack([np.ones(T_obs), x_pos, x_neg])fit = OLS(y, X).fit()b_pos, b_neg = fit.params[1], fit.params[2]# Wald test of H0: beta+ = beta-R = np.array([[0, 1, -1]])Rb = R @ fit.paramswald =float(Rb @ np.linalg.inv(R @ fit.cov_params() @ R.T) @ Rb)pval =1- f_dist.cdf(wald, dfn=1, dfd=fit.df_resid)lines = ["NARDL long-run estimates, lrm1 on lny+ and lny-",f" beta+ (income increases) : {b_pos:.4f}",f" beta- (income decreases) : {b_neg:.4f}",f" asymmetry beta+ - beta- : {b_pos - b_neg:.4f}","",f"Wald test H0: beta+ = beta- F = {wald:.4f} p = {pval:.4f}"," "+ ("reject symmetry"if pval <0.05else"cannot reject symmetry")]out ="\n".join(lines)import sys; sys.stdout.write(out +"\n"); sys.stdout.flush()
NARDL long-run estimates, lrm1 on lny+ and lny-
beta+ (income increases) : 0.6918
beta- (income decreases) : 0.5506
asymmetry beta+ - beta- : 0.1412
Wald test H0: beta+ = beta- F = 28.0476 p = 0.0000
reject symmetry
230
Code
quietly import delimited "../data/ur-vecm-finland.csv", clearquietlygen t = _nquietlytsset tquietlygen d_lny = D.lnyquietlygen inc = max(d_lny, 0)quietlygen dec = min(d_lny, 0)quietlyreplace inc = 0 in 1quietlyreplace dec = 0 in 1quietlygen lny_pos = sum(inc)quietlygen lny_neg = sum(dec)regress lrm1 lny_pos lny_negdisplay""display"Wald test of long-run symmetry, H0: beta+ = beta-"test lny_pos = lny_neg
Source | SS df MS Number of obs = 106
-------------+---------------------------------- F(2, 103) = 786.76
Model | 9.78238716 2 4.89119358 Prob > F = 0.0000
Residual | .640340814 103 .006216901 R-squared = 0.9386
-------------+---------------------------------- Adj R-squared = 0.9374
Total | 10.422728 105 .099264076 Root MSE = .07885
------------------------------------------------------------------------------
lrm1 | Coefficient Std. err. t P>|t| [95% conf. interval]
-------------+----------------------------------------------------------------
lny_pos | .6918403 .049127 14.08 0.000 .5944084 .7892722
lny_neg | .5506074 .0736753 7.47 0.000 .4044897 .696725
_cons | 2.814896 .0198815 141.58 0.000 2.775465 2.854326
------------------------------------------------------------------------------
Wald test of long-run symmetry, H0: beta+ = beta-
( 1) lny_pos - lny_neg = 0
F( 1, 103) = 28.05
Prob > F = 0.0000
The two long-run coefficients answer the question directly: does real money respond to income increases the same way it responds to income decreases? The Wald test in the Python and Stata tabs, and the pair of multipliers in the R tab, are the same hypothesis expressed three ways.
A caution specific to this application. Finnish real income rose over most of 1958–1984, so the negative partial sum lny_neg moves far less than the positive one. When one of the two partial sums has little variation, \(\beta^{-}\) is estimated imprecisely and the symmetry test has low power. That is a property of the sample rather than of the method, and it is the first thing to check before reporting an asymmetry result.
Spurious regression in a new costume
NARDL builds its regressors by cumulating. Both partial sums are \(I(1)\) by construction, so a high \(R^2\) in the levels regression means nothing on its own — the bounds test is what establishes that the relationship is real.
This is the spurious regression problem of Part I in a new costume.
Part VIII — Panel Unit Roots and Cointegration
ταχέως· φιλεῖ γάρ πως τὰ τοιαῦθʼ ἑτέρᾳ τρέπεσθαι.
quickly — such things have a way of turning the other way
Single-series unit root tests have poor power, and macro samples are short. Pooling \(N\) units multiplies the information without needing a longer sample, and the power gain is large.
The cost is a set of new assumptions, and the tests differ mainly in which of them they are willing to make.
Test
\(H_0\)
\(H_1\)
AR coefficient
Cross-section dependence
Levin-Lin-Chu
Unit root in all units
All stationary
Homogeneous
Not allowed
Im-Pesaran-Shin
Unit root in all units
Some stationary
Heterogeneous
Not allowed
Fisher-ADF
Unit root in all units
Some stationary
Heterogeneous
Not allowed
Hadri
All stationary
Some have a unit root
—
Not allowed
Pesaran CIPS
Unit root in all units
Some stationary
Heterogeneous
Allowed
Warning
Cross-sectional dependence invalidates the first four tests. Countries share business cycles, states share national shocks, firms share industry conditions. When the units are correlated the effective sample size is far smaller than \(NT\), and LLC, IPS, Fisher and Hadri all over-reject — sometimes dramatically.
Test for it first with Pesaran’s CD test. If it rejects, use CIPS, or cross-section demean the data before applying IPS.
The data here is G7 log real GDP per capita from the Penn World Table — seven of the most tightly synchronised economies in the world. If cross-sectional dependence is ever going to matter, it matters here, and the results below should be read with that in mind rather than at face value.
pwt_df <-read.csv("../data/ur-vecm-pwt.csv")pdat <- plm::pdata.frame(pwt_df, index =c("isocode", "year"))llc <- plm::purtest(lgdppc ~1, data = pdat, test ="levinlin", lags ="AIC")ips <- plm::purtest(lgdppc ~1, data = pdat, test ="ips", lags ="AIC")summary(llc)summary(ips)
Penn World Table: 7 countries, 350 observations, 1970-2019
Test Statistic p_value Verdict
Levin-Lin-Chu -4.0079 0.0000 reject unit root
Im-Pesaran-Shin -0.6459 0.2592 unit root
Fisher-ADF 24.9207 0.0354 reject unit root
Pesaran CD statistic = 12.8058 p = 0
Large CD means the units share common shocks and the tests above over-reject.
Code
import numpy as np, pandas as pd, warningswarnings.filterwarnings("ignore")from scipy import statsfrom statsmodels.tsa.stattools import adfullerpwt = pd.read_csv("../data/ur-vecm-pwt.csv")# Fisher-type panel unit root: combine the individual ADF p-valuesrows = []for iso, grp in pwt.groupby("isocode"): r = adfuller(grp.sort_values("year")["lgdppc"].values, autolag="AIC", regression="ct") rows.append({"country": iso, "ADF": r[0], "p": r[1]})tab = pd.DataFrame(rows)chi2 =-2* np.log(tab["p"]).sum()pchi =1- stats.chi2.cdf(chi2, df=2*len(tab))# Pesaran CD test on the differenced serieswide = pwt.pivot(index="year", columns="isocode", values="lgdppc").sort_index()dmat = wide.diff().dropna()cmat = dmat.corr().valuesN, T = cmat.shape[0], len(dmat)cd = np.sqrt(2* T / (N * (N -1))) * cmat[np.triu_indices(N, 1)].sum()lines = [tab.round(4).to_string(index=False),f"\nFisher chi2({2*len(tab)}) = {chi2:.4f} p = {pchi:.4f}"," "+ ("reject: some units stationary"if pchi <0.05else"fail to reject: unit root in all units"),f"\nPesaran CD = {cd:.4f} p = {2*(1-stats.norm.cdf(abs(cd))):.4g}"]out ="\n".join(lines)import sys; sys.stdout.write(out +"\n"); sys.stdout.flush()
country ADF p
CAN -2.9395 0.1498
DEU -2.5858 0.2865
FRA -4.1523 0.0053
GBR -2.2624 0.4548
ITA -1.7254 0.7396
JPN -0.9851 0.9461
USA -2.3642 0.3988
Fisher chi2(14) = 20.9139 p = 0.1039
fail to reject: unit root in all units
Pesaran CD = 12.8058 p = 0
295
--- Levin-Lin-Chu ---
Levin–Lin–Chu unit-root test for lgdppc
---------------------------------------
H0: Panels contain unit roots Number of panels = 7
Ha: Panels are stationary Number of periods = 50
AR parameter: Common Asymptotics: N/T -> 0
Panel means: Included
Time trend: Included Cross-sectional means removed
ADF regressions: 1 lag
LR variance: Bartlett kernel, 11.00 lags average (chosen by LLC)
------------------------------------------------------------------------------
Statistic p-value
------------------------------------------------------------------------------
Unadjusted t -4.7741
Adjusted t* -1.0027 0.1580
------------------------------------------------------------------------------
--- Im-Pesaran-Shin ---
Im–Pesaran–Shin unit-root test for lgdppc
-----------------------------------------
H0: All panels contain unit roots Number of panels = 7
Ha: Some panels are stationary Number of periods = 50
AR parameter: Panel-specific Asymptotics: T,N -> Infinity
Panel means: Included sequentially
Time trend: Included Cross-sectional means removed
ADF regressions: No lags included
------------------------------------------------------------------------------
Fixed-N exact critical values
Statistic p-value 1% 5% 10%
------------------------------------------------------------------------------
t-bar -1.7051 -2.880 -2.670 -2.560
t-tilde-bar -1.6255
Z-t-tilde-bar -0.4850 0.3138
------------------------------------------------------------------------------
--- Hadri (H0 is stationarity) ---
Hadri LM test for lgdppc
--------------------------
H0: All panels are stationary Number of panels = 7
Ha: Some panels contain unit roots Number of periods = 50
Time trend: Not included Asymptotics: T, N -> Infinity
Heteroskedasticity: Not robust sequentially
LR variance: (not used) Cross-sectional means removed
------------------------------------------------------------------------------
Statistic p-value
------------------------------------------------------------------------------
z 40.9236 0.0000
------------------------------------------------------------------------------
Read the Pesaran CD statistic before the panel unit root results. It is enormous, which is exactly what seven synchronised advanced economies should produce: the G7 share global shocks, and their per-capita income series are far from independent.
That makes the LLC, IPS and Fisher results unreliable in the direction of over-rejection, whatever they say. The demean option in the Stata tab is the cheap partial fix — subtracting the cross-sectional mean at each date removes a single common factor — and it is why those tests are run with it rather than without.
The lesson generalises
The lesson generalises well beyond this dataset. A panel unit root test that ignores cross-sectional dependence is not a more powerful version of a time-series test; it is a differently biased one.
Test for dependence first, and let the answer choose the test.
Westerlund’s is the one to prefer where possible. Residual-based tests impose a common factor restriction that is often rejected by the data; testing the error correction coefficient directly avoids it, and the four statistics \(G_t, G_a, P_t, P_a\) split into group-mean and pooled versions.
The pooled mean group estimator of Pesaran, Shin & Smith:
panel_df <-read.csv("../data/ur-vecm-panel.csv")# Mean Group: one long-run regression per state, then averagemg_slopes <-sapply(split(panel_df, panel_df$id), function(sub) {coef(lm(lgsp ~ lpcap, data = sub))["lpcap"]})cat(sprintf("Mean Group long-run slope = %.4f (SE %.4f)\n",mean(mg_slopes), sd(mg_slopes) /sqrt(length(mg_slopes))))
Munnell (1990) US state production panel: N = 48 states, T = 17 years
Mean Group long-run slope beta = 1.2504 SE = 0.1553
Mean adjustment speed alpha = -0.1700 SE = 0.0281
Share of states with alpha < 0: 79%
Pooled OLS slope for comparison: 1.0644
A large gap between pooled and Mean Group is evidence of heterogeneity.
Code
import numpy as np, pandas as pdfrom statsmodels.regression.linear_model import OLSpanel = pd.read_csv("../data/ur-vecm-panel.csv")N_ = panel["id"].nunique()betas, alphas = [], []for _, grp in panel.groupby("id"): xi, yi = grp["lpcap"].values, grp["lgsp"].values T_ =len(xi) b = OLS(yi, np.column_stack([np.ones(T_), xi])).fit().params[1] betas.append(b) ect = yi - b * xi dy = np.diff(yi) a = OLS(dy, np.column_stack([np.ones(T_ -1), ect[:-1]])).fit().params[1] alphas.append(a)betas, alphas = np.array(betas), np.array(alphas)pooled = OLS(panel["lgsp"], np.column_stack([np.ones(len(panel)), panel["lpcap"]])).fit()lines = [f"Munnell (1990) panel: N = {N_} states",f"Mean Group long-run slope beta = {betas.mean():.4f} "f"SE = {betas.std(ddof=1)/np.sqrt(N_):.4f}",f"Mean adjustment speed alpha = {alphas.mean():.4f} "f"SE = {alphas.std(ddof=1)/np.sqrt(N_):.4f}",f"Share of states with alpha < 0: {100*(alphas<0).mean():.0f}%",f"\nPooled OLS slope for comparison: {pooled.params[1]:.4f}"]out ="\n".join(lines)import sys; sys.stdout.write(out +"\n"); sys.stdout.flush()
Munnell (1990) panel: N = 48 states
Mean Group long-run slope beta = 1.2504 SE = 0.1553
Mean adjustment speed alpha = -0.1700 SE = 0.0281
Share of states with alpha < 0: 79%
Pooled OLS slope for comparison: 1.0644
221
Code
quietly import delimited "../data/ur-vecm-panel.csv", clearquietlydestring id year lgsp lpcap lpc lemp, replaceforcequietly xtset id yeardisplay"--- Westerlund (2007) error-correction panel cointegration ---"* xtwest has no kernel() option; the long-runvariancewindow is lrwindow()xtwest lgsp lpcap, constant lags(2) leads(1) lrwindow(3)display""display"--- Mean Group estimator ---"xtpmg d.lgsp d.lpcap, lr(l.lgsp l.lpcap) ec(ec) replace mg
--- Westerlund (2007) error-correction panel cointegration ---
Calculating Westerlund ECM panel cointegration tests..........
Results for H0: no cointegration
With 48 series and 1 covariate
-----------------------------------------------+
Statistic | Value | Z-value | P-value |
-----------+-----------+-----------+-----------|
Gt | -1.446 | 2.555 | 0.995 |
Ga | -2.578 | 5.809 | 1.000 |
Pt | -7.896 | 2.145 | 0.984 |
Pa | -1.678 | 3.983 | 1.000 |
-----------------------------------------------+
--- Mean Group estimator ---
------------------------------------------------------------------------------
Mean Group Estimation: Error Correction Form XTPMG v2.0.1
(Estimate results saved as mg)
------------------------------------------------------------------------------
------------------------------------------------------------------------------
D.lgsp | Coefficient Std. err. z P>|z| [95% conf. interval]
-------------+----------------------------------------------------------------
ec |
lpcap |
L1. | -.0785278 .8076352 -0.10 0.923 -1.661464 1.504408
-------------+----------------------------------------------------------------
SR |
ec | -.1759926 .0302731 -5.81 0.000 -.2353268 -.1166584
|
lpcap |
D1. | -.5960204 .2002561 -2.98 0.003 -.9885152 -.2035256
|
_cons | 2.032764 .5751488 3.53 0.000 .9054934 3.160035
------------------------------------------------------------------------------
The Mean Group slope and the pooled OLS slope are estimates of different things, and the gap between them is the finding. Pooled OLS forces one long-run elasticity on all 48 states; the Mean Group average allows each state its own and then averages. When they diverge, the pooled estimator is inconsistent, not merely less efficient.
The share of states with a negative adjustment coefficient is the sanity check. Error correction requires \(\alpha < 0\); if a substantial minority of units come out positive, the specification is wrong for those units and averaging over them hides it.
xtwest options, and errors that do not fail
xtwest takes lrwindow(), not kernel() and bandwidth(). Passing the latter produces option kernel() not allowed, r(198).
Because Stata prints its errors as ordinary output, the chunk fails without failing. Grep the rendered output for r( followed by a number.
Practice
λύει πεδήσας, οὐδʼ ἀεὶ λαβὼν ἔχει.
having bound, it looses — nor does it keep for ever what it took
Where the three languages part company. R has the deepest coverage: urca and tsDyn between them handle everything above except the structural decomposition. Python has the reduced-form VECM but no FEVD method, no structural routine and no threshold model. Stata has the strongest panel tools by a distance — xtwest and xtpmg have no clean R or Python equivalent — and the weakest nonlinear ones.
Four objects written from their definitions
Four objects in this deck had to be written from their definitions, because no language ships them:
the Ng-Perron M-tests
the Gregory-Hansen break search
the FEVD in Python
the long-run impact matrix \(\boldsymbol\Xi\)
In every case the hand-coded version is about twenty lines, agrees across languages to four decimals, and is easier to check than a package call.
Break the data differently. Split the FRED sample at 1984 and re-run the Johansen test on each half. The Great Moderation changed the volatility of both series; whether it changed the cointegrating vector is a question the Gregory-Hansen test of Part III can answer directly.
Change the deterministic specification. Re-run the Finland system with ecdet = "const" and with ecdet = "trend". The rank can change. This is the single most consequential researcher choice in the whole procedure, and it is usually left at a default.
Change the frequency. Aggregate the FRED quarterly data to annual and repeat. The long-run relationship should survive temporal aggregation; the short-run dynamics will not.
Change the estimator, not the model. Compare cajorls, tsDyn::VECM(estim = "ML") and tsDyn::VECM(estim = "2OLS") on the same specification. ML and two-step OLS are both consistent; the gap between them is a finite-sample diagnostic.
Add a variable. Put a fourth series into the FRED system — investment, or the unemployment rate — and see whether the rank rises. A second cointegrating vector is a second long-run relationship, and it needs an economic story before it can be believed.
Extend the sample. These decks fix a vintage. Re-run ur-vecm-data.R a year from now and check that the conclusions are stable. Cointegration results that flip with twelve new observations were never solid.
Exercises — Unit Roots and Cointegration
Apply all six unit root tests from Part II to the i1d and i2 series in ur-vecm-sim.csv. For i1d, show what happens when the none, drift and trend specifications are used, and explain which is correct and why the other two mislead.
Confirm that i2 is \(I(2)\): show that the ADF test fails to reject on the level and on the first difference, and rejects only on the second difference. What would have gone wrong had you concluded \(I(1)\) after the first step?
Simulate a near-unit-root process with \(\rho = 0.97\) and \(T = 100\). Apply ADF, DF-GLS and the Ng-Perron \(MZ_t\) 1000 times and compare rejection rates. Rank the three tests by power, and check that the size is right by repeating with \(\rho = 1\).
Take the FRED gs10 and gs1 series. Are they cointegrated? Test with Engle-Granger, Johansen and the ARDL bounds test. The expectations hypothesis predicts a vector of \([1,-1]\) — test that restriction formally with blrtest.
Run the Gregory-Hansen test on the FRED lgdp and lpce pair. Does allowing one break change the conclusion relative to Engle-Granger? Where does the estimated break fall, and does it correspond to anything in US macroeconomic history?
Re-run the Johansen test on the Finland system with \(K = 1, 2, 3, 4\) and with each of the five deterministic specifications. Tabulate the selected rank in all twenty combinations. How much of the answer is the data, and how much is the specification?
Exercises — VECM and Extensions
Extract \(\hat{\boldsymbol\alpha}\) from the Finland VECM and test weak exogeneity for each variable with alrtest. Then re-estimate the system as a single-equation ECM conditional on the weakly exogenous variables. Do the long-run estimates change?
Test the restriction \(\beta_{lny} = -1\) on the Finland cointegrating vector with blrtest, i.e. a unit long-run income elasticity of money demand. Report the likelihood ratio statistic and its degrees of freedom, and say what rejection would mean economically.
Apply the same restriction test to the GDP-consumption pair from Part V: is the cointegrating vector \([1,-1]\), as the permanent income hypothesis requires? Run it on the full sample and on the post-1984 subsample separately.
Compute the FEVD of the Finland system at horizons 1, 4, 8, 20 and 40 for all four variables, not just lrm1. Which variable is most exogenous in the long run by this measure, and does that agree with the weak exogeneity tests of exercise 1?
Fit a linear VECM and a TVECM to the r3 and r6 pair, then run TVECM.HStest with 199 bootstrap replications. Is the asymmetry statistically supported? Report the regime sizes alongside the coefficients.
Verify the permanent-transitory count: compute \(\boldsymbol\Xi\) for the Finland system at \(r = 1\) and at \(r = 2\), and confirm that its rank is \(K - r\) in both cases. What happens to the rank if you impose a rank the data reject?
Granger, C.W.J. & Newbold, P. (1974). “Spurious Regressions in Econometrics.” Journal of Econometrics 2(2), 111–120. doi:10.1016/0304-4076(74)90034-7
Dickey, D.A. & Fuller, W.A. (1979). “Distribution of the Estimators for Autoregressive Time Series with a Unit Root.” JASA 74(366), 427–431. doi:10.2307/2286348
Johansen, S. (1988). “Statistical Analysis of Cointegration Vectors.” Journal of Economic Dynamics and Control 12(2–3), 231–254. doi:10.1016/0304-4076(88)90041-3
Johansen, S. & Juselius, K. (1990). “Maximum Likelihood Estimation and Inference on Cointegration.” Oxford Bulletin of Economics and Statistics 52(2), 169–210. doi:10.1111/j.1468-0084.1990.mp52002003.x
Kwiatkowski, D., Phillips, P.C.B., Schmidt, P. & Shin, Y. (1992). “Testing the Null Hypothesis of Stationarity.” Journal of Econometrics 54(1–3), 159–178. doi:10.1016/0304-4076(92)90104-Y
Elliott, G., Rothenberg, T.J. & Stock, J.H. (1996). “Efficient Tests for an Autoregressive Unit Root.” Econometrica 64(4), 813–836. doi:10.2307/2171846
Ng, S. & Perron, P. (2001). “Lag Length Selection and the Construction of Unit Root Tests with Good Size and Power.” Econometrica 69(6), 1519–1554. doi:10.1111/1468-0262.00256
Zivot, E. & Andrews, D.W.K. (1992). “Further Evidence on the Great Crash, the Oil-Price Shock, and the Unit-Root Hypothesis.” JBES 10(3), 251–270. doi:10.2307/1391541
Perron, P. (1989). “The Great Crash, the Oil Price Shock, and the Unit Root Hypothesis.” Econometrica 57(6), 1361–1401. doi:10.2307/1913712
Pesaran, M.H., Shin, Y. & Smith, R.J. (2001). “Bounds Testing Approaches to the Analysis of Level Relationships.” Journal of Applied Econometrics 16(3), 289–326. doi:10.1002/jae.616
Stock, J.H. & Watson, M.W. (1993). “A Simple Estimator of Cointegrating Vectors in Higher Order Integrated Systems.” Econometrica 61(4), 783–820. doi:10.2307/2951763
Enders, W. & Siklos, P.L. (2001). “Cointegration and Threshold Adjustment.” JBES 19(2), 166–176. doi:10.1198/073500101316970395
Shin, Y., Yu, B. & Greenwood-Nimmo, M. (2014). “Modelling Asymmetric Cointegration and Dynamic Multipliers in a Nonlinear ARDL Framework.” In Festschrift in Honor of Peter Schmidt, 281–314. doi:10.1007/978-1-4899-8008-3_9
Westerlund, J. (2007). “Testing for Error Correction in Panel Data.” Oxford Bulletin of Economics and Statistics 69(6), 709–748. doi:10.1111/j.1468-0084.2007.00477.x
Pesaran, M.H. (2007). “A Simple Panel Unit Root Test in the Presence of Cross-Section Dependence.” Journal of Applied Econometrics 22(2), 265–312. doi:10.1002/jae.951