Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

EGM fixes a regular grid of post-decision states and inverts the first-order conditions to recover the states that lead to them. With two states, the recovered points form a curvilinear mesh, on which standard multilinear interpolation does not apply. This notebook builds ENGINE (ENdogenous Grid INterpolation and Extrapolation), the interpolant of Section 4, one step at a time. It checks the two conditions ENGINE needs, carries out its two passes by hand on a small mesh and matches the result against the library, then compares ENGINE with the curvilinear method of White (2015) and with Delaunay triangulation on a larger mesh.

1The Curvilinear Grid

Index the exogenous grid by j=1,…,J along one axis and k=1,…,K along the other. The inversion sends node (j,k) to a point (xjk,yjk) of the endogenous mesh and delivers the function value fjk there as well. The points no longer line up in rows and columns, yet each one keeps the indices of the node that generated it. Call the points sharing one k the k-slice. Below, the paper’s figure shows the same mesh twice, once in physical coordinates and once in index coordinates; the shaded cell has the same four nodes in both.

<Figure size 1425x675 with 2 Axes>

2ENGINE’s Two Conditions

ENGINE requires two properties of the mesh, both about the ordering of its nodes (Section 4):

  1. Fold-free. No cell turns inside out: the Jacobian determinant of each cell’s bilinear coordinate map is positive at all four corners. At the lower-left corner the condition is

    𝒥jk=(xj+1,k−xjk)(yj,k+1−yjk)−(xj,k+1−xjk)(yj+1,k−yjk)>0.
  2. Index-preserved monotonicity (IPM). Each coordinate increases along its own index: xj+1,k>xjk along every k-slice and yj,k+1>yjk along every j-line.

Together they make the map from index space to the mesh a homeomorphism, so a bracket found on the index grid is a valid bracket on the mesh. The small mesh used for the walkthrough is the one in the paper’s two-pass figure, with values from the smooth function f(x,y)=−1/(2+x+y). corner_jacobians returns the four corner determinants of every cell and ipm_violations counts the steps that break IPM:

mesh: 7 x 8 nodes
smallest corner Jacobian:   0.0114
IPM violations (x-steps, y-steps): (0, 0)

Every corner determinant is positive and no step breaks IPM, so ENGINE applies. The paper verifies both conditions on every mesh it reports (Section 4) with these same two checks, corner_jacobians and ipm_violations.

3The Two Passes, Step by Step

Take the query (x∗,y∗)=(0.3,0.55), away from the vertical line x=0.5 on which this mesh happens to be unwarped.

Pass 1 works slice by slice. On each k-slice the coordinate x increases along j, so a one-dimensional bracket search finds where the vertical line x=x∗ crosses the slice. Linear interpolation along the slice then gives the crossing’s ordinate yˆk and the value fˆk there.

Pass 2 works across slices. Because the ordinates yˆk increase with k, a second bracket search finds the two consecutive slices whose crossings straddle y∗. One more linear interpolation in y between their values gives fˆ(x∗,y∗).

The cell below carries out both passes with np.interp alone, visiting every slice, and prints the intermediate quantities.

k  y_hat_k  f_hat_k
-  -------  --------
1  0.0000   -0.43516
2  0.1777   -0.40386
3  0.3581   -0.37628
4  0.5274   -0.35370
5  0.6702   -0.33669
6  0.7867   -0.32402
7  0.8920   -0.31340
8  1.0000   -0.30316

bracketing slices: k = 4 and 5, weight t = 0.1585
two passes by hand: -0.351002565161307
Engine2DInterp:     -0.351002565161307
true f(x*, y*):     -0.350877192982456

The hand computation and the library agree to the last printed digit. Both differ from the true value in the fourth decimal place, the error of linear interpolation on cells this coarse. The figure redraws the paper’s two-pass illustration at this query, with the values fˆk computed above in panel (b).

<Figure size 2100x825 with 2 Axes>

Panel (a) shows Pass 1, with circles at the crossings (x∗,yˆk), unevenly spaced because the mesh is warped at x∗=0.3. In panel (b), which shows Pass 2, the line through the circles is linear interpolation of fˆk in yˆk, and the star is its value at y∗.

The Order of the Passes

Running Pass 1 along the other index family (slices of constant j, at y=y∗) defines a different interpolant on a warped mesh, because sequential one-dimensional interpolation does not commute across warped coordinate lines. axes_order=(1, 0) selects that order.

k-slices first: -0.351002565161307
j-lines first:  -0.351015900956107
difference:     -1.3e-05
interpolation error (k-slices first): -1.3e-04
difference at the paper's query (0.5, 0.5): 0.0e+00

At this query the two orders differ by about a tenth of the interpolation error. At the paper’s own query, on the unwarped line x=0.5, they coincide. Section 4 recommends walking the regular axis first, the one with the smaller bracket displacement, and reports that reversing the order in the two-asset model leaves converged accuracy essentially unchanged.

4A Larger Mesh

The comparisons below use the synthetic EGM-style mesh of the repository’s benchmarks and tests, make_warped_egm_grid: geometrically spaced axes from 0.1 to 100 bent by a sinusoidal warp, with values f(x,y)=log⁡(x+y+1). Spacing is finest near the origin, as on an asset grid. The cell below confirms both conditions, then measures with empirical_ipo_alpha the bracket-displacement constant αˆ: the largest number of cells a query’s bracket moves between adjacent slices.

mesh: 40 x 40 nodes
smallest corner Jacobian:   4.09e-04
IPM violations (x-steps, y-steps): (0, 0)
bracket displacement alpha_hat: 16

This constant governs only how far a single query’s search must look between slices. ENGINE’s default search window assumes a displacement of at most two cells. When a windowed search misses, ENGINE detects the miss and falls back to a full search of the slice. Hence the window affects speed and never the result.

The Regrid

In each backward step, the solver needs the policy on a regular grid of current states, so it evaluates the interpolant at every node of a rectangular target grid. Engine2DInterp.regrid does this in one call. It walks the targets in sorted order, starting each bracket search where the previous one ended. The target grid here is 60×60 evenly spaced points on [12,90]2. With bilinear_contains, the cell checks that every target lies inside some mesh cell, where no method needs to extrapolate. It also confirms that the regrid matches separate scattered queries at the same points.

targets inside the mesh: 3600 of 3600
largest gap, regrid vs scattered queries: 0.0e+00

Accuracy

All three methods now interpolate at the same 3,600 targets. The curvilinear method is HARK’s Curvilinear2DInterp, which locates each target’s cell by a sector walk and solves for its coordinates within the cell. Delaunay triangulation is SciPy’s LinearNDInterpolator, which triangulates the nodes and returns NaN at any query outside their convex hull. Every target lies inside the mesh, so the NaN count printed below checks that claim. Each statistic then covers all 3,600 targets for every method.

Method       NaN  Mean abs error  95th pct  Max abs error
-----------  ---  --------------  --------  -------------
ENGINE       0    1.35e-03        2.45e-03  3.53e-03
Curvilinear  0    1.36e-03        2.57e-03  3.54e-03
Delaunay     0    1.16e-03        2.55e-03  3.48e-03
<Figure size 2250x675 with 4 Axes>

All three are piecewise-linear interpolants of second order in the mesh width. On this mesh their errors agree in size. Section 4 reaches the same verdict for the two structured methods inside a full solve of the two-asset model, where their robust Euler errors agree within two percent.

Cost

The solver rebuilds the interpolant from a new mesh in every backward step, so the relevant cost of one regrid includes each method’s construction step. ENGINE’s constructor does not build cell data. It only validates the input arrays (shapes, finite entries, IPM). The curvilinear method computes per-cell data for its coordinate solve, while triangulation builds its simplices from scratch. Each timing covers construction plus one evaluation at the 3,600 targets, as the best of seven calls after one warm-up call, all three on one thread.

Method       Build + regrid (ms)
-----------  -------------------
ENGINE       0.043
Curvilinear  0.357
Delaunay     6.557

Stored with the archive, regrid_percall_abrs.csv holds the paper’s per-call measurement: one realign of the regular target grid at the converged mesh of each of the three interpolation-diagnostic grids, ENGINE against the curvilinear method with its construction step included, best of five calls on one thread on the benchmark machine. It is written by code/ssj/bench_regrid_percall.py.

Grid    (nB, nA)  ENGINE (ms)  Curvilinear (ms)  Ratio
------  --------  -----------  ----------------  -----
Coarse  (24, 20)  0.020        0.543             27.3
Medium  (48, 40)  0.067        1.330             19.7
Fine    (96, 80)  0.256        4.687             18.3

The ratio column divides the curvilinear time by ENGINE’s time. Section 4 reports these per-call ratios together with the full-solve gains they produce.