Skip to content

Hansen's Econometrics: the book's computations in StatsPAI

Bruce Hansen's Econometrics (Princeton University Press, 2022) ships with the Stata, R and MATLAB programs behind its tables. This page maps the empirical work of each chapter to a StatsPAI call. The Stata programs were run in Stata 18 and replayed through sp.stata; where a number below is compared with Stata, that replay or the test tests/reference_parity/test_hansen_methods_stata_parity.py is the evidence.

The book's data are on the author's page and are not redistributed here. The code blocks run on simulated data so that they can be copied as they are.

import numpy as np
import pandas as pd
import statspai as sp

rng = np.random.default_rng(2022)
n = 500
df = pd.DataFrame({
    "educ": rng.integers(8, 21, n).astype(float),
    "exper": rng.uniform(0, 40, n),
    "firm": rng.integers(0, 50, n),
})
df["exper2"] = df.exper**2 / 100
df["lwage"] = (
    0.5 + 0.10 * df.educ + 0.04 * df.exper - 0.07 * df.exper2
    + rng.normal(scale=0.5, size=n)
)

Chapter by chapter

Chapter In the book StatsPAI
3, 4 Least squares, leverage, HC0 to HC3, clustered standard errors sp.regress(..., vce='hc2'), cluster=, sp.estat(fit, 'leverage')
8 Constrained least squares, efficient minimum distance sp.cnsreg
9 Wald tests, functions of coefficients sp.test, sp.lincom, sp.nlcom
10 Jackknife and bootstrap sp.jackknife, sp.bootstrap
11 Principal components, factor models, rotation sp.pca, sp.factor, .rotate()
12 2SLS, LIML, overidentification, control function sp.iv, sp.estat(fit, 'overid'), 'endogenous', 'firststage'
13 GMM sp.iv(method='gmm'), sp.gmm
14 Autoregressions, Newey-West sp.ardl, sp.regress(vce='hac', hac_lags=)
15 VAR, impulse responses, structural VAR sp.var, sp.varsoc, sp.svar, fit.irf(), fit.fevd()
16 Unit roots, KPSS, cointegration sp.unitroot, sp.vec, sp.johansen
17 Fixed and random effects, Hausman-Taylor, dynamic panels sp.panel, sp.xthtaylor, sp.xtabond, sp.xtdpdsys
18 Difference in differences sp.did, sp.panel(method='fe')
19 to 21 Kernel regression, series, regression discontinuity sp.lpoly, sp.series, sp.rdrobust
23 Nonlinear least squares, threshold and kink models sp.nls, sp.threshold
24 Quantile regression sp.qreg, sp.sqreg
25, 26 Binary and multiple choice sp.logit, sp.probit, sp.margins, sp.mlogit, sp.clogit, sp.nlogit, sp.mprobit, sp.mixlogit
27 Censoring and selection sp.tobit, sp.heckman
28 Model selection and averaging sp.model_average
29 Lasso, ridge and their relatives sp.rlasso, sp.lasso_select

Constrained regression (chapter 8)

The book restricts the Mankiw-Romer-Weil growth regression so that three coefficients sum to zero. The same idea with a wage equation:

free = sp.regress("lwage ~ educ + exper + exper2", data=df, vce="robust")
cls = sp.cnsreg("lwage ~ educ + exper + exper2", df, "exper + exper2 = 0",
                vce="robust")
emd = sp.cnsreg("lwage ~ educ + exper + exper2", df, "exper + exper2 = 0",
                method="emd")
print(cls.params.round(4).to_dict())
print(cls.model_info["constraint_test"])

method='cls' is Stata's cnsreg. method='emd' is the efficient minimum distance estimator, which has the smaller variance under heteroskedasticity. constraint_test is the Wald test of the restriction at the unrestricted estimate. Here it rejects, as it should: the data were not generated under the restriction, and both restricted estimators are then inconsistent for the unrestricted coefficients.

A function of the coefficients three ways (chapters 9 and 10)

The experience level at which the wage profile peaks is -50 * b_exper / b_exper2. The delta method, the jackknife and the bootstrap each give a standard error.

peak = "-50 * _b[exper] / _b[exper2]"
delta = sp.nlcom(free, peak)

def statistic(d):
    b = sp.regress("lwage ~ educ + exper + exper2", data=d).params
    return -50 * b["exper"] / b["exper2"]

jack = sp.jackknife(df, statistic)
boot = sp.bootstrap(df, statistic, n_boot=500, seed=1)
print(round(delta["estimate"], 2), round(delta["se"], 2),
      round(jack.se, 2), round(boot.se, 2))

The jackknife involves no random number, so it reproduces Stata's jackknife prefix digit for digit. It is consistent for smooth functions such as this one and not for quantiles. The bootstrap depends on the draws: the same procedure in two programs gives standard errors that agree up to simulation error.

Principal components and factors (chapter 11)

tests = pd.DataFrame(
    rng.normal(size=(n, 1)) * [0.8, 0.7, 0.6, 0.5]
    + rng.normal(size=(n, 4)) * 0.6,
    columns=["word", "sentence", "letter", "spelling"],
)
pc = sp.pca(tests)
print(pc.eigenvalues.round(3))
fa = sp.factor(tests, method="ml", n_factors=1)
print(fa.loadings.round(3))
index = pc.scores(tests)["Comp1"]

Eigenvectors are defined up to sign. StatsPAI signs each one so that its elements sum to a positive number, which is what Stata prints; R may show the opposite sign. Loadings are unrotated.

With two or more factors the loadings can be rotated towards a pattern in which each variable loads on one factor:

two = pd.DataFrame(
    rng.normal(size=(n, 1)) * [0.8, 0.7, 0.6, 0, 0, 0]
    + rng.normal(size=(n, 1)) * [0, 0, 0, 0.8, 0.7, 0.6]
    + rng.normal(size=(n, 6)) * 0.5,
    columns=["a1", "a2", "a3", "b1", "b2", "b3"],
)
rotated = sp.factor(two, method="pcf", n_factors=2).rotate("varimax")
print(rotated.loadings.round(2))
oblique = sp.factor(two, method="pcf", n_factors=2).rotate("promax")
print(oblique.factor_correlation.round(3))

'varimax' keeps the factors uncorrelated. 'promax' lets them correlate. A rotation changes the loadings and nothing else: the uniquenesses and the fit are the same.

Overidentification after 2SLS (chapter 12)

z = rng.normal(size=(n, 3))
df["z1"], df["z2"], df["z3"] = z.T
v = rng.normal(size=n)
df["school"] = 12 + z @ [0.8, 0.5, 0.3] + v
df["y"] = 1 + 0.1 * df.school + 0.5 * v + rng.normal(size=n)
fit = sp.iv("y ~ exper + (school ~ z1 + z2 + z3)", data=df, robust="robust")
over = sp.estat(fit, "overid", print_results=False)
print(round(over["score"], 3), {k: round(v, 3) for k, v in over["iid_errors"].items()})

After a robust fit the headline statistic is Hansen's J at the 2SLS residuals, which is also the robust score statistic Stata reports. iid_errors holds Sargan's and Basmann's statistics, which Stata shows with estat overid, forcenonrobust.

If an excluded instrument is a linear combination of the others and of the exogenous regressors, sp.iv drops it, warns, and lists it in model_info['omitted_instruments'].

Time-invariant regressors in a panel (chapter 17)

units, periods = 150, 6
effect = rng.normal(size=units)
pan = pd.DataFrame({"firm": np.repeat(np.arange(units), periods)})
pan["sales"] = rng.normal(size=len(pan))
pan["debt"] = rng.normal(size=len(pan)) + effect[pan.firm]
pan["sector"] = rng.integers(0, 2, units)[pan.firm].astype(float)
pan["listed"] = ((rng.normal(size=units) + effect) > 0)[pan.firm].astype(float)
pan["invest"] = (
    1 + 0.5 * pan.sales - 0.3 * pan.debt + 0.2 * pan.sector + 0.4 * pan.listed
    + effect[pan.firm] + rng.normal(size=len(pan))
)
ht = sp.xthtaylor("invest ~ sales + debt + sector + listed", pan, id="firm",
                  endog=["debt", "listed"])
print(ht.params.round(3).to_dict())
print(ht.model_info["ti_endogenous"], round(ht.model_info["rho"], 3))

Fixed effects cannot estimate the coefficients of sector and listed, which do not vary within firm. Random effects can, but assumes debt and listed are unrelated to the firm effect. sp.xthtaylor lets the regressors named in endog= be correlated with it and instruments them with the within variation of the time-varying regressors and the firm means of the exogenous ones. It needs at least as many exogenous time-varying regressors as endogenous time-invariant ones.

Series regression (chapter 20)

df["tenure"] = rng.uniform(0, 40, n)
df["pay"] = (
    2 + 0.08 * df.tenure - 0.0015 * df.tenure**2 + 0.05 * df.educ
    + rng.normal(scale=0.4, size=n)
)
profile = sp.series("pay ~ educ", df, "tenure")
print(profile.model_info["order"])
print(profile.model_info["cv"].round(2))
print(profile.model_info["function"].iloc[::25].round(3))
spline = sp.series("pay ~ educ", df, "tenure", basis="spline", degree=2)

The effect of tenure is a polynomial whose degree is chosen by leave-one-out cross-validation. model_info['cv'] shows the criterion for each candidate, and model_info['function'] the fitted profile and its slope with pointwise standard errors. The intervals take the degree as given, so they understate the uncertainty where the function bends. basis='spline' behaves better near the ends of the data.

Nonlinear least squares (chapter 23)

df["x"] = np.exp(rng.normal(size=n) / 2)
df["q"] = 2 + 3 * df.x**0.5 + rng.normal(scale=0.3, size=n)
nl = sp.nls("q ~ {a} + {b} * x^{c}", df, start={"a": 1, "b": 1, "c": 1},
            vce="robust")
print(nl.params.round(3).to_dict())
print(sp.test(nl, "c = 0.5")["pvalue"] > 0.01)

The formula is Stata's nl syntax with the parameters in braces. A Python function f(params, data) works too. The sum of squares of a nonlinear model can have several local minima, so try more than one set of starting values and keep the fit with the smallest diagnostics['Residual SS']. When a parameter is a threshold at which the function jumps, the estimate is usable and its normal standard error is not; use sp.threshold.

Threshold and kink models (chapter 23)

df["tip"] = rng.uniform(0, 1, n)
df["move"] = (
    1 + 0.5 * df.x + (df.tip > 0.4) * (1.0 + 0.8 * df.x)
    + rng.normal(scale=0.5, size=n)
)
thr = sp.threshold("move ~ x", df, "tip", n_boot=200, seed=1)
print(round(thr.model_info["threshold"], 3), thr.model_info["threshold_ci"])
print(thr.model_info["regimes"].round(3))
print(thr.model_info["linearity_test"]["pvalue"])

The threshold is estimated by least squares over the sample values of tip. Its interval is the set of values a likelihood-ratio test does not reject, because the estimate is not normal. The standard errors of the other coefficients take the threshold as known. Whether there is a threshold at all is a separate question, and the usual F test does not answer it: under the null the threshold is not identified. n_boot= runs the bootstrap version.

kink=True fits a continuous function whose slope changes at an unknown point. There the point is estimated at the usual rate and gets a standard error:

df["growth"] = (
    2 - 1.0 * np.minimum(df.tip - 0.4, 0) + 2.0 * np.maximum(df.tip - 0.4, 0)
    + rng.normal(scale=0.3, size=n)
)
kink = sp.threshold("growth ~ 1", df, "tip", kink=True)
print(kink.params.round(3).to_dict(), round(kink.std_errors["threshold"], 3))

This is the model the book fits with nl to the Reinhart-Rogoff data. sp.threshold searches a grid first, so it does not depend on starting values.

Multiple choice (chapter 26)

cases = 400
trips = pd.DataFrame({
    "trip": np.repeat(np.arange(cases), 3),
    "mode": np.tile(["bus", "car", "train"], cases),
    "fare": rng.normal(size=3 * cases),
    "wealth": np.repeat(rng.normal(size=cases), 3),
})
utility = (
    -1.0 * trips.fare + 0.6 * trips.wealth * (trips["mode"] == "car")
    + rng.normal(size=3 * cases)
)
trips["chosen"] = (utility == utility.groupby(trips.trip).transform("max")).astype(int)
probit = sp.mprobit(
    trips, y="chosen", x="fare", case_vars="wealth", chid="trip", alt="mode",
    correlation="independent", stddev="homoskedastic",
)
print(probit.params.round(3).to_dict())
print(probit.model_info["covariance"])

x= holds the regressors that differ across alternatives (one coefficient each), case_vars= those that describe the traveller (one coefficient per alternative). The default covariance is unstructured, which removes the independence of irrelevant alternatives and is weakly identified in most data; start from the independent model. The choice probabilities are integrated by quadrature, so two runs give the same numbers. Stata's cmmprobit simulates them and agrees to three or four digits.

A nested logit with one nest held at independence, as in the book:

nested = sp.nlogit(
    trips, y="chosen", x="fare", chid="trip", alt="mode",
    nests={"road": ["bus", "car"], "rail": ["train"]},
)
print(round(nested.params["lambda:road"], 3))

Model selection and averaging (chapter 28)

candidates = [
    "lwage ~ educ + exper",
    "lwage ~ educ + exper + exper2",
    "lwage ~ educ + exper + exper2 + I(exper**3)",
]
avg = sp.model_average(candidates, df)          # jackknife weights
print(avg.table[["k", "aic", "bic", "cv", "w_mma", "w_jma"]].round(3))
print(avg.selected)
return_at_10 = avg.average(
    lambda fit: fit.predict(pd.DataFrame({"educ": [12.0], "exper": [11.0],
                                          "exper2": [1.21]}))[0]
    - fit.predict(pd.DataFrame({"educ": [12.0], "exper": [10.0],
                                "exper2": [1.0]}))[0]
)

table holds every criterion and all four sets of weights; method= chooses which one defines the averaged coefficients and predictions. Jackknife weights are the default because they stay optimal under heteroskedasticity. No standard errors are reported: an estimator that was selected or averaged is not normal around the coefficient of one model.

Running the book's do-files

sp.stata runs the chapters' Stata code on a DataFrame.

out = sp.stata(
    """
    constraint define 1 exper + exper2 = 0
    cnsreg lwage educ exper exper2, constraints(1) r
    """,
    data=df,
)
assert np.allclose(out.params, cls.params)

What it still declines in these chapters is listed in docs/dev/2026-10-05-hansen-econometrics-review.md.