๐ง ๐ง ๐ง Not the final version ๐ง ๐ง ๐ง
Today we estimate the retirement model of Class 18 by the method of simulated moments of Class 19: simulated data, moments, the criterion, the disutility of work and the discount factor recovered, and the standard errors checked against a Monte Carlo.
Running the code for this lecture
The code for this class is in the folder session20-nov3/ of the course code
repository. We write it together in class, starting from the pre-code notebook
msm_practice_pre.ipynb, which has the data, the headings and the stubs of the three
functions we write live. The complete notebook msm_practice.ipynb has all the code of
this page. Two modules in the same folder carry what we already have: retirement.py,
the DC-EGM solver and simulator of Class 18, and msm.py, the generic
part of the estimator of Class 19.
You should have cloned that repository already โ if not, the instructions are in the algorithms and complexity lecture.
Update your copy before the class. Editing a file in place makes git pull refuse to
update it, so discard whatever you changed while experimenting:
cd sb-dse-code
git reset --hard HEAD
git pullNothing in this repository is submitted, so experiment freely โ but copy anything you want to keep out of it first, or commit that to a branch of your own.
Setting up the Python environment is covered in Python.
What we have and what we writeยถ
The course architecture again: model object, solver, simulator, estimator. The first three exist, and todayโs work is the fourth:
| Piece | Where | Status |
|---|---|---|
retirement, solve_dcegm, worker | retirement.py, Class 18 | given |
simulate(m, sol, M0, seed) | retirement.py, Class 18 | given, with a prob=True switch that also returns the choice probabilities |
estimate, jacobian, vcov, jtest | msm.py, Class 19 | given, model-free |
moments(M, C, D) | this notebook | we write it |
simmoments(theta, S, seed) | this notebook | we write it |
criterion(theta, W, S) | this notebook | we write it |
The estimator in msm.py takes the criterion and the simulated moments as function
arguments, so the three functions we write are the whole interface between the model
and the method.
Source
import sys, time, pathlib, tempfile, urllib.request
import numpy as np
import matplotlib.pyplot as plt
CODE = pathlib.Path('../gh-code/session20-nov3') # the DC-EGM code of Class 18 and the estimator of Class 19
if not CODE.exists(): # otherwise fetch the modules from the code repository
CODE = pathlib.Path(tempfile.mkdtemp())
url = 'https://raw.githubusercontent.com/fediskhakov/sb-dse-code/main/session20-nov3/'
for f in ('retirement.py', 'msm.py'):
urllib.request.urlretrieve(url + f, CODE / f)
sys.path.insert(0, str(CODE))
from retirement import retirement, solve_dcegm, simulate
from msm import estimate, jacobian, vcov, jtest---------------------------------------------------------------------------
HTTPError Traceback (most recent call last)
Cell In[1], line 9
5 if not CODE.exists(): # otherwise fetch the modules from the code repository
6 CODE = pathlib.Path(tempfile.mkdtemp())
7 url = 'https://raw.githubusercontent.com/fediskhakov/sb-dse-code/main/session20-nov3/'
8 for f in ('retirement.py', 'msm.py'):
----> 9 urllib.request.urlretrieve(url + f, CODE / f)
10 sys.path.insert(0, str(CODE))
11 from retirement import retirement, solve_dcegm, simulate
12 from msm import estimate, jacobian, vcov, jtest
File /opt/hostedtoolcache/Python/3.12.15/x64/lib/python3.12/urllib/request.py:240, in urlretrieve(url, filename, reporthook, data)
223 """
224 Retrieve a URL into a temporary location on disk.
225
(...) 236 data file as well as the resulting HTTPMessage object.
237 """
238 url_type, path = _splittype(url)
--> 240 with contextlib.closing(urlopen(url, data)) as fp:
241 headers = fp.info()
243 # Just return the local path and the "headers" for file://
244 # URLs. No sense in performing a copy unless requested.
File /opt/hostedtoolcache/Python/3.12.15/x64/lib/python3.12/urllib/request.py:215, in urlopen(url, data, timeout, cafile, capath, cadefault, context)
213 else:
214 opener = _opener
--> 215 return opener.open(url, data, timeout)
File /opt/hostedtoolcache/Python/3.12.15/x64/lib/python3.12/urllib/request.py:521, in OpenerDirector.open(self, fullurl, data, timeout)
519 for processor in self.process_response.get(protocol, []):
520 meth = getattr(processor, meth_name)
--> 521 response = meth(req, response)
523 return response
File /opt/hostedtoolcache/Python/3.12.15/x64/lib/python3.12/urllib/request.py:630, in HTTPErrorProcessor.http_response(self, request, response)
627 # According to RFC 2616, "2xx" code indicates that the client's
628 # request was successfully received, understood, and accepted.
629 if not (200 <= code < 300):
--> 630 response = self.parent.error(
631 'http', request, response, code, msg, hdrs)
633 return response
File /opt/hostedtoolcache/Python/3.12.15/x64/lib/python3.12/urllib/request.py:559, in OpenerDirector.error(self, proto, *args)
557 if http_err:
558 args = (dict, 'default', 'http_error_default') + orig_args
--> 559 return self._call_chain(*args)
File /opt/hostedtoolcache/Python/3.12.15/x64/lib/python3.12/urllib/request.py:492, in OpenerDirector._call_chain(self, chain, kind, meth_name, *args)
490 for handler in handlers:
491 func = getattr(handler, meth_name)
--> 492 result = func(*args)
493 if result is not None:
494 return result
File /opt/hostedtoolcache/Python/3.12.15/x64/lib/python3.12/urllib/request.py:639, in HTTPDefaultErrorHandler.http_error_default(self, req, fp, code, msg, hdrs)
638 def http_error_default(self, req, fp, code, msg, hdrs):
--> 639 raise HTTPError(req.full_url, code, msg, hdrs, fp)
HTTPError: HTTP Error 404: Not FoundThe โdataโ are simulated as in Class 18, with a seed of their own: 1000 workers with initial wealth uniform on , taste shocks and income shocks , over periods. The distribution of initial wealth is treated as part of the model, known to the estimator. The parameters we pretend not to know:
| true value | estimated | |
|---|---|---|
| disutility of work | 0.5 | yes |
| discount factor | 0.95 | yes |
| taste shock scale | 0.1 | fixed, see what breaks |
| income shock , return , wage | 0.2, 1.05, 1 | fixed |
def model(theta):
'''The retirement model at the parameters we estimate; everything else at the values of Class 18'''
delta, beta = theta
return retirement(beta=beta, delta=delta, sigma=0.1, sigma_y=0.2, nquad=10)
theta0 = np.array([0.5, 0.95]) # true delta and beta
N = 1000
rng = np.random.default_rng(3)
M0 = rng.uniform(2, 20, N) # initial wealth of the sample
m0 = model(theta0)
Md, Cd, Dd = simulate(m0, solve_dcegm(m0), M0, seed=rng) # the data: wealth, consumption, work status
print(f'share working by period {np.round(Dd.mean(0), 2)}')
print(f'mean wealth by period {np.round(Md.mean(0), 1)}')Every column is a period of life: the share working falls from 83% to zero in the last period, where no one works, and wealth is run down from 11 to below 2.
Which of the two profiles would move if doubled, and in which direction?
Which would move if fell to 0.9, and why is the first period of each profile the least informative?
Momentsยถ
The two profiles above are the moments: the share working in periods and mean wealth in periods , numbers. The last period of work status is left out because it is zero whatever the parameters, and the first period of wealth because it is the observed initial condition.
The function returns one row per individual, the contributions , so that the
data moments are a column mean and their covariance is one call of np.cov:
def moments(M, C, D):
'''Moment contributions h(z_i), one row per individual: work status in periods 0..T-2, wealth in periods 1..T-1'''
return np.column_stack([D[:, :-1], M[:, 1:]])
h = moments(Md, Cd, Dd) # N x q
mhat, Omega = h.mean(0), np.cov(h.T) # data moments and their covariance
q, K = mhat.size, theta0.size
Wdiag = np.diag(1 / np.diag(Omega)) # first-step weighting: inverse variances
print(f'{q} moments, {K} parameters; standard errors of the data moments:')
print(f' work status {np.round(np.sqrt(np.diag(Omega)[:9] / N), 3)}')
print(f' wealth {np.round(np.sqrt(np.diag(Omega)[9:] / N), 2)}')The two kinds of moments live on different scales: a residual in a work share is of
order 0.01, a residual in mean wealth of order 0.1, and the inverse variances in
Wdiag put them on the same footing. With the identity matrix the wealth residuals
would decide everything and the work shares nothing.
Consumption is in the data too. Why is it left out of the moments here, and what would it add?
Retirement is absorbing, so the work status of one individual in periods 4 and 5 is strongly correlated. Where does this show up in , and does the diagonal see it?
The simulator with fixed drawsยถ
The simulated moments solve the model at and simulate copies of the data, initial wealth included, with the same seed at every , as in the criterion of Class 19. The simulated sample is independent of the data, which the asymptotics require.
The parameter walls keep the optimizer where the solver works: and .
def simmoments(theta, S=5, seed=1):
'''Simulated moments at theta: S copies of the data, initial wealth included, same draws at every theta'''
delta, beta = theta
if not (0 < delta < 5 and 0.5 < beta < 1): return None # outside: no solution, or no retirement at all
m = model(theta)
sol = solve_dcegm(m) # about 5 milliseconds
rng = np.random.default_rng(seed) # fixed draws: the same generator state at every theta
Ms, Cs, Ds = simulate(m, sol, rng.uniform(2, 20, S * N), seed=rng)
return moments(Ms, Cs, Ds).mean(0)
def criterion(theta, W, S=5, seed=1):
'''The MSM criterion g' W g'''
ms = simmoments(theta, S, seed)
if ms is None: return 1e10 # a wall for the optimizer
g = mhat - ms
return g @ W @ g
t0 = time.perf_counter()
Q0 = criterion(theta0, Wdiag)
print(f'criterion at the truth {Q0:.4f}, one evaluation with S = 5 in {time.perf_counter() - t0:.3f} seconds')The criterion at the true parameters is not zero: the data moments carry sampling noise and the simulated moments carry simulation noise, and the criterion measures their distance. The figure cuts the criterion along and along through the truth, and zooms in on a window of one thousandth wide.
Source
fig, ax = plt.subplots(1, 3, figsize=(16, 4.5))
dg, bg, dz = np.linspace(0.3, 0.7, 41), np.linspace(0.9, 0.99, 37), np.linspace(0.4995, 0.5005, 51)
for S, col in ((1, 'C0'), (5, 'C1')):
ax[0].plot(dg, [criterion([d, theta0[1]], Wdiag, S) for d in dg], color=col, lw=1, label=f'S = {S}')
ax[1].plot(bg, [criterion([theta0[0], b], Wdiag, S) for b in bg], color=col, lw=1)
ax[2].plot(dz, [criterion([d, theta0[1]], Wdiag, S) for d in dz], '.-', color=col, ms=3, lw=0.7)
ax[0].set(xlabel='ฮด', yscale='log', title=f'Criterion along ฮด, ฮฒ = {theta0[1]}')
ax[1].set(xlabel='ฮฒ', yscale='log', title=f'Criterion along ฮฒ, ฮด = {theta0[0]}')
ax[2].set(xlabel='ฮด', title='Zoom: the steps of the indicator moments')
ax[0].legend()
for a in ax: a.axvline(theta0[0] if a is not ax[1] else theta0[1], color='k', lw=0.5, ls=':'); a.grid(color='0.9')
plt.show()Both cuts have a clear minimum at the truth, three orders of magnitude deep: and are identified by these moments.
The zoom shows steps: the work status is an indicator , so a simulated share only changes when a fixed draw crosses the moving threshold, by at a time. The steps are smaller with than with , and they never disappear.
The steps are why
estimateuses NelderโMead and whyjacobiantakes finite differences with a step , wide enough to average over many of them: with the derivative of a work share is one step divided by , a number that has nothing to do with the slope.
Where exactly along a wealth path does one draw crossing the threshold change the simulated moments, and does the effect stay in one period?
The wealth moments are continuous in given the work status. Why do they still jump at the steps?
Estimation and standard errorsยถ
The first step minimizes the criterion with the diagonal weighting matrix from a deliberately poor start, and then restarts NelderโMead from its own solution: on a criterion with steps the simplex can collapse early, and a restart from the solution with a fresh simplex costs a second or two and catches it.
t0 = time.perf_counter()
th1, Q1, nfev = estimate(criterion, x0=(0.3, 0.9), args=(Wdiag,)) # first step, from a poor start
th1, Q1, nfev2 = estimate(criterion, x0=th1, args=(Wdiag,)) # restart from the solution
se1 = np.sqrt(np.diag(vcov(jacobian(simmoments, th1, S=5), Wdiag, Omega, N, S=5)))
print(f'first step delta {th1[0]:.4f} ({se1[0]:.4f}) beta {th1[1]:.4f} ({se1[1]:.4f}) '
f'{nfev + nfev2} evaluations in {time.perf_counter() - t0:.1f} s')The first step lands within a tenth of a standard error of the truth in both parameters. On other data sets drawn from the same model it can land two standard errors away, and that is not a bug either: eighteen moments from an absorbing retirement process and a persistent wealth path are strongly correlated, and a diagonal treats them as independent, so the estimate follows whichever noise the sample has in common across periods.
The second step uses the optimal weighting matrix , starts from the first-step estimate, and delivers the -test with degrees of freedom.
t0 = time.perf_counter()
Wopt = np.linalg.inv(Omega)
th2, Q2, nfev = estimate(criterion, x0=th1, args=(Wopt,))
th2, Q2, nfev2 = estimate(criterion, x0=th2, args=(Wopt,))
se2 = np.sqrt(np.diag(vcov(jacobian(simmoments, th2, S=5), Wopt, Omega, N, S=5)))
J, p = jtest(Q2, N, S=5, df=q - K)
print(f'second step delta {th2[0]:.4f} ({se2[0]:.4f}) beta {th2[1]:.4f} ({se2[1]:.4f}) '
f'{nfev + nfev2} evaluations in {time.perf_counter() - t0:.1f} s')
print(f'J-test {J:.1f} on {q - K} degrees of freedom, p-value {p:.3f}')The second step lands within half a standard error of and , with standard errors three times smaller than the first step for and five times smaller for . That is what the optimal buys when the moments are correlated: it weights the differences between neighboring periods, which the diagonal cannot see.
The -test does not reject the model that generated the data: on 16 degrees of freedom, . It does not always come out this way. On nine data sets drawn from the same model with different seeds the p-values ranged from 0.02 to 0.99, with one rejection at the 5% level, and one rejection in twenty is what a 5% test does. Altonji & Segal (1996) is also in play: has a condition number of and is estimated from one thousand individuals, and tests based on its inverse over-reject in samples of this size.
The table every MSM paper carries is the fit of the moments, here as a figure: data moments with two standard error bands against the simulated moments at the estimate.
Source
ms = simmoments(th2)
se_m = np.sqrt(np.diag(Omega) / N)
fig, ax = plt.subplots(1, 2, figsize=(12, 4.5))
for a, sl, off, lab in ((ax[0], slice(0, 9), 0, 'share working'), (ax[1], slice(9, 18), 1, 'mean wealth')):
t = np.arange(9) + off
a.errorbar(t, mhat[sl], yerr=2 * se_m[sl], fmt='o', ms=4, capsize=3, label='data, ยฑ 2 s.e.')
a.plot(t, ms[sl], 's--', ms=4, lw=0.8, color='C3', label='model at the estimate')
a.set(xlabel='period', title=f'Fit of the moments: {lab}'); a.grid(color='0.9'); a.legend()
plt.show()Both profiles are matched within the bands in every period, which is what the -test summarizes. When the test does reject, the figure usually shows why: the optimal does not close the gaps one by one, it minimizes a quadratic form in which a run of positively correlated gaps on the same side of the data is cheap, and such a run is what the -test picks up.
The second-step standard errors are much smaller than the first-step ones. Which part of the sandwich changed, and why does the gain depend on the correlation of the moments?
Eighteen moments and one thousand individuals: how many numbers does estimate, and would you trust the -test more with nine moments?
Monte Carlo check of the standard errorsยถ
The standard errors come from a formula with a numerical Jacobian computed over steps. The honest check is a Monte Carlo: draw ten data sets from the true model with different seeds, initial wealth included, run the first-step estimator on each, and compare the spread of the estimates to the formula.
Every replication keeps the same simulation seed in simmoments, so only the data
change, and the first step is enough for the check because its formula and the
second stepโs share every ingredient but .
t0 = time.perf_counter()
sol0, ests = solve_dcegm(m0), []
for r in range(10):
rng_r = np.random.default_rng(100 + r)
Mr, Cr, Dr = simulate(m0, sol0, rng_r.uniform(2, 20, N), seed=rng_r) # a new data set from the true model
hr = moments(Mr, Cr, Dr)
mh_r, W_r = hr.mean(0), np.diag(1 / np.var(hr, axis=0))
def crit_r(theta, W):
'''The criterion with this replication's data moments; the simulation seed stays the same'''
ms = simmoments(theta)
return 1e10 if ms is None else (mh_r - ms) @ W @ (mh_r - ms)
th, _, _ = estimate(crit_r, x0=(0.3, 0.9), args=(W_r,))
th, _, _ = estimate(crit_r, x0=th, args=(W_r,))
ests.append(th)
ests = np.array(ests)
print(f'{ests.shape[0]} replications in {time.perf_counter() - t0:.0f} s')
print(f'mean of the estimates delta {ests[:, 0].mean():.4f} beta {ests[:, 1].mean():.4f}')
print(f'std of the estimates delta {ests[:, 0].std():.4f} beta {ests[:, 1].std():.4f}')
print(f'formula, first step above delta {se1[0]:.4f} beta {se1[1]:.4f}')The estimator is centered on the truth, and the Monte Carlo spread matches the formula within 10% for both parameters, with a Jacobian that averages over the steps. Ten replications is a rough check, not a verdict: the standard error of a standard deviation from ten draws is about a quarter of its value, so agreement within 30% is all the check can promise.
The Monte Carlo keeps the simulation seed fixed across replications. Which source of noise in the formula does it then not measure?
How many replications would it take to tell a formula standard error of 0.003 from a true one of 0.004?
What breaks: the taste shock scaleยถ
The scale of the taste shocks was fixed at its true value. Adding it to the
parameter vector is one line in model and one in the walls, and it is where the
method shows its limits. The criterion along through the truth, computed with a
version of model that takes three parameters:
def model3(theta):
'''The retirement model with the taste shock scale as a third parameter'''
delta, beta, sigma = theta
return retirement(beta=beta, delta=delta, sigma=sigma, sigma_y=0.2, nquad=10)
def criterion3(theta, W, S=5, seed=1):
if not (0 < theta[0] < 5 and 0.5 < theta[1] < 1 and theta[2] > 0): return 1e10
m = model3(theta)
rng = np.random.default_rng(seed)
Ms, Cs, Ds = simulate(m, solve_dcegm(m), rng.uniform(2, 20, S * N), seed=rng)
g = mhat - moments(Ms, Cs, Ds).mean(0)
return g @ W @ g
for s in (0.02, 0.05, 0.08, 0.1, 0.12, 0.15, 0.2):
print(f'sigma {s:<5} criterion {criterion3([theta0[0], theta0[1], s], Wdiag):.4f}')Compare with the cuts along and above, which climb more than an order of magnitude within a few hundredths of the truth. Along the minimum is at the truth, 0.007, but the criterion is flat: it rises to 0.02 at and , and to 0.06โ0.07 only at and . The moments barely respond to , which is the small entry in that the asymptotics of Class 19 warned about:
the standard error of is large, and the finite-difference Jacobian in that direction is dominated by the steps, so even the reported standard error is unreliable
NelderโMead estimating all three parameters stalls on the flat, stepped criterion: started from it stops at , from at , both with a criterion several times above the minimum, and a restart does not help; only a start near the truth finds
on other data sets the sampling noise of eighteen moments can move the flat minimum away from the truth, because the weak signal of cannot pull it back
What would identify is a moment that measures how retirement varies given wealth: the dispersion of the retirement age among workers with similar initial wealth, for instance. The practical task asks for it.
- Altonji, J. G., & Segal, L. M. (1996). Small-Sample Bias in GMM Estimation of Covariance Structures. Journal of Business & Economic Statistics, 14(3), 353โ366. 10.1080/07350015.1996.10524661
- Iskhakov, F., Jรธrgensen, T., Rust, J., & Schjerning, B. (2017). The endogenous grid method for discrete-continuous dynamic choice models with (or without) taste shocks. Quantitative Economics, 8(2), 317โ365. https://doi.org/10.3982/QE643
- McFadden, D. (1989). A Method of Simulated Moments for Estimation of Discrete Response Models Without Numerical Integration. Econometrica, 57(5), 995. 10.2307/1913621
- French, E. (2005). The Effects of Health, Wealth, and Wages on Labour Supply and Retirement Behaviour. The Review of Economic Studies, 72(2), 395โ427. 10.1111/j.1467-937X.2005.00337.x
- Gourinchas, P.-O., & Parker, J. A. (2002). Consumption over the Life Cycle. Econometrica, 70(1), 47โ89. 10.1111/1468-0262.00269
- Corbae, D., & Guvenen, F. (2024). Method of Moments Estimators. In Quantitative Economics with Heterogeneity: A Guidebook. Princeton University Press.