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.

Interpolation and Extrapolation

In dynamic programming, the value function V(x)V(x) is computed on a discrete grid but must be evaluated at arbitrary points during the solution process. State transitions x′=g(x,a)x' = g(x, a) can produce next-period states that fall between grid points (requiring interpolation) or outside the grid (requiring extrapolation).

pylcm does not use RegularGridInterpolator or similar pre-built tools because it needs to support grids whose points are only known at runtime — for example, continuous stochastic processes whose locations depend on distributional parameters supplied via params.

This notebook explains how pylcm handles interpolation and extrapolation, using a CRRA utility function u(w)=w1−γ1−γu(w) = \frac{w^{1-\gamma}}{1-\gamma} on a coarse wealth grid as a running example.

pylcm’s two-step design

pylcm evaluates functions on arbitrary points in two steps:

  1. Coordinate finder: Convert a physical value (e.g., wealth = 150) to a generalized coordinate — a fractional index into the grid. Values inside the grid produce coordinates in [0,n−1][0, n-1]; values outside produce coordinates outside this range.

  2. map_coordinates: Take the generalized coordinates and the pre-computed array of function values. Perform linear interpolation (for coordinates inside [0,n−1][0, n-1]) or linear extrapolation (for coordinates outside).

Each grid type provides its own coordinate finder, optimized for its spacing pattern. The map_coordinates function is the same for all grid types.

LinSpacedGrid

For a linearly spaced grid with start, stop, and n_points, the coordinate finder uses the O(1) formula:

coordinate=value−startstep_length,step_length=stop−startnpoints−1\text{coordinate} = \frac{\text{value} - \text{start}}{\text{step\_length}}, \quad \text{step\_length} = \frac{\text{stop} - \text{start}}{n_\text{points} - 1}

Values inside the grid produce coordinates in [0,npoints−1][0, n_\text{points}-1]. Values below start produce negative coordinates; values above stop produce coordinates above npoints−1n_\text{points}-1. Both cases lead to linear extrapolation in map_coordinates.

Grid points: [  1.        45.333336  89.66667  134.       178.33334  222.66667
 267.       311.33334  355.6667   400.      ]
V on grid:   [-2.         -0.29704425 -0.21121    -0.17277369 -0.14976616 -0.13403012
 -0.12239801 -0.11334886 -0.10604945 -0.1       ]
Inside grid:
  wealth = 25.0  →  coordinate = 0.5414  (in [0, 9])
  wealth = 100.0  →  coordinate = 2.2331  (in [0, 9])
  wealth = 250.0  →  coordinate = 5.6165  (in [0, 9])

Outside grid:
  wealth = 0.5  →  coordinate = -0.0113  (< 0)
  wealth = 450.0  →  coordinate = 10.1278  (> 9)
  Wealth   Coordinate  Approximated          True       Error
------------------------------------------------------------
    25.0       0.5414     -1.078099     -0.400000   -0.678099
   100.0       2.2331     -0.202251     -0.200000   -0.002251
   250.0       5.6165     -0.126858     -0.126491   -0.000367
     0.5      -0.0113     -2.019206     -2.828427    0.809221
   450.0      10.1278     -0.093177     -0.094281    0.001104
Loading...

LogSpacedGrid

For a logarithmically spaced grid, points are denser at lower values — ideal for functions with high curvature near zero (like CRRA utility).

The coordinate finder get_logspace_coordinate works by:

  1. Transforming value, start, stop to log space

  2. Finding the bounding grid points (via their ranks in log space)

  3. Linearly interpolating between the ranks in the original (physical) space

This gives a coordinate that accounts for the non-uniform spacing. With the same number of grid points, a log-spaced grid captures the curvature of CRRA much better than a linearly spaced one.

LinSpaced grid: [  1.        45.3       89.700005 134.       178.3      222.7
 267.       311.30002  355.7      400.      ]
LogSpaced grid: [  1.    1.9   3.8   7.4  14.3  27.9  54.3 105.6 205.6 400. ]
Loading...

Continuous stochastic processes (Normal)

In order to discretize continuous stochastic processes, pylcm computes their grid points from distributional parameters (e.g., mu, sigma for a Normal shock) which may only be known at runtime (when supplied via params). Because the points are determined dynamically, a continuous stochastic process cannot use the O(1) linspace formula — instead, it uses get_irreg_coordinate internally.

get_irreg_coordinate handles arbitrary point sequences:

  1. Use jnp.searchsorted to find the bounding grid points (O(log n))

  2. Linearly interpolate between the bounding points to get the fractional coordinate

  3. For values outside the grid, extrapolate using the slope of the nearest segment

Shock grid points: [-2.5000000e+00 -1.6666666e+00 -8.3333313e-01  5.9604645e-08
  8.3333349e-01  1.6666669e+00  2.5000000e+00]

Shock query points and coordinates:
  ε =  -3.0  →  coordinate = -0.600  (outside)
  ε =  -1.0  →  coordinate =  1.800  (inside)
  ε =   0.5  →  coordinate =  3.600  (inside)
  ε =   2.0  →  coordinate =  5.400  (inside)
  ε =   3.0  →  coordinate =  6.600  (outside)
Loading...

PiecewiseLinSpacedGrid

Some models feature eligibility thresholds — e.g., a means-tested program that applies only below a wealth cutoff. The value function may jump at the threshold (eligible households receive a transfer, ineligible ones do not). To ensure the threshold is a grid point (avoiding interpolation across the discontinuity), PiecewiseLinSpacedGrid lets you place a breakpoint at the threshold.

For a grid with segments [a,b)[a, b) and [b,c][b, c]:

  1. Segment selection: jnp.searchsorted on breakpoints determines which segment a value belongs to

  2. Local coordinate: get_linspace_coordinate within the segment

  3. Global coordinate: offset by the cumulative number of points in preceding segments

The breakpoint bb is the first point of the second segment, guaranteeing it is a grid point. Because map_coordinates interpolates linearly between adjacent grid points, and the two points straddling the breakpoint (bb and the last point of the previous segment) are adjacent in the array, the interpolation never crosses the discontinuity — this is guaranteed by the implementation.

Total grid points: 12
Grid: [  1.       13.2      25.5      37.7      50.       50.      108.3
 166.7     225.      283.30002 341.7     400.     ]

Breakpoint at wealth = 50 is at index 5: grid[5] = 50.0

V just below threshold (grid[4]): 0.2172
V at threshold        (grid[5]): -0.2828
Loading...

map_coordinates internals

pylcm’s map_coordinates (src/lcm/ndimage.py) is a modified version of JAX’s jax.scipy.ndimage.map_coordinates. The key difference is in how it handles values outside the grid.

The function _compute_indices_and_weights is where the magic happens:

lower_index = jnp.clip(jnp.floor(coordinate), 0, input_size - 2)
upper_weight = coordinate - lower_index
lower_weight = 1 - upper_weight
  • The lower index is clipped to [0,n−2][0, n-2], ensuring valid array access

  • But the weight is not clipped — it is simply coordinate - lower_index

For coordinates inside [0,n−1][0, n-1], the weight falls in [0,1][0, 1], giving standard linear interpolation. For coordinates outside this range:

CoordinateLower indexWeightResult
c∈[0,n−1]c \in [0, n-1]⌊c⌋\lfloor c \rfloorc−⌊c⌋∈[0,1]c - \lfloor c \rfloor \in [0,1]Linear interpolation
c<0c < 00c<0c < 0Linear extrapolation using first segment
c>n−1c > n-1n−2n-2c−(n−2)>1c - (n-2) > 1Linear extrapolation using last segment

JAX’s version, by contrast, clips or fills values outside the grid (depending on mode), which does not give linear extrapolation.

coordinate =  -0.5 (below grid)
  lower_index = 0, upper_index = 1
  lower_weight = 1.50, upper_weight = -0.50
  result = 1.50 × V[0] + -0.50 × V[1]
         = 1.50 × 10 + -0.50 × 20
         = 5.0

coordinate =   1.7 (inside grid)
  lower_index = 1, upper_index = 2
  lower_weight = 0.30, upper_weight = 0.70
  result = 0.30 × V[1] + 0.70 × V[2]
         = 0.30 × 20 + 0.70 × 30
         = 27.0

coordinate =   4.5 (above grid)
  lower_index = 3, upper_index = 4
  lower_weight = -0.50, upper_weight = 1.50
  result = -0.50 × V[3] + 1.50 × V[4]
         = -0.50 × 40 + 1.50 × 50
         = 55.0

Multi-dimensional interpolation

When the value function depends on multiple continuous states, each dimension gets its own coordinate finder. The map_coordinates function then performs multi-linear interpolation (or extrapolation) by combining the per-dimension coordinates.

For a 2D case (e.g., wealth ×\times income shock), this is bilinear interpolation: the function value at a query point is a weighted average of the 4 nearest grid points, with weights determined by the per-dimension fractional coordinates.

Wealth grid: [ 10.        65.700005 121.4      177.1      232.90001  288.6
 344.30002  400.      ]
Shock grid:  [-2. -1.  0.  1.  2.]
V shape:     (8, 5) (wealth x shock)

Query: wealth = 150.0, shock = 0.30000001192092896
Wealth coordinate: 2.5128
Shock coordinate:  2.3000
Interpolated V:    -0.165309
True V:            -0.163136
Error:             -0.002173

Summary

Grid typeCoordinate finderComplexityWhen to use
LinSpacedGridget_linspace_coordinateO(1)Uniformly spaced state variables
LogSpacedGridget_logspace_coordinateO(1)States with high curvature at low values (e.g., wealth with CRRA)
PiecewiseLinSpacedGridsearchsorted + get_linspace_coordinateO(log k) + O(1)States with breakpoints (e.g., eligibility thresholds)
IrregSpacedGridget_irreg_coordinateO(log n)Arbitrary point placement
_ContinuousStochasticProcessesget_irreg_coordinateO(log n)Stochastic processes with runtime-determined points

All coordinate finders produce generalized coordinates that map_coordinates uses for linear interpolation (inside the grid) or linear extrapolation (outside the grid). The two-step design keeps the interpolation logic generic while letting each grid type optimize its coordinate mapping.