The Nelson–Siegel curve
Write for the zero rate, in percent, of maturity years (case study 1.6.4). The Nelson–Siegel curve builds it from three shapes:
Rule: The Nelson–Siegel curve
is the level far out: as grows, and fall to 0. At the short end tends to 1 and to 0, so tends to .
adds a hump. The time scale , in years, sets where the shapes bend.
is largest at , where it is 0.2984, so the hump peaks at a maturity of about . With :
| maturity | 0.5 | 1 | 2 | 5 | 10 | 30 |
|---|---|---|---|---|---|---|
| 0.8848 | 0.7869 | 0.6321 | 0.3672 | 0.1987 | 0.0667 | |
| 0.106 | 0.1804 | 0.2642 | 0.2851 | 0.1919 | 0.0667 |
Case study 3.6.5 found that yield changes move mostly in three shapes: level, slope and curvature. Nelson–Siegel builds the curve itself from three such shapes: a constant, falling from 1 towards 0, and the hump .
The quotes
The ten quotes are invented for illustration. They come from a Nelson–Siegel curve with and , plus noise of typical size 3 basis points. The noise is drawn with the seed np.random.default_rng(445), and each quote is rounded to 0.001%.
A basis point (bp) is 0.01 percentage points (case study 1.6.4).
| maturity (years) | 0.5 | 1 | 2 | 3 | 5 | 7 | 10 | 15 | 20 | 30 |
|---|---|---|---|---|---|---|---|---|---|---|
| zero rate (%) | 3.2 | 3.517 | 3.953 | 4.213 | 4.438 | 4.46 | 4.538 | 4.559 | 4.578 | 4.556 |
The fit chooses and to make the sum of squared errors smallest:
Here is the quote at maturity , and is in squared percentage points. An of 0.002374 over ten quotes is a root-mean-square miss of 1.54 bp.
Try it
ns is the curve. The quotes are made from it with the seed, then rounded, as described above.
The loop prints and for , the table above. The last line finds the peak of on a fine grid.
With τ fixed: linear least squares
Fix , and the curve is a combination of three known columns: a column of ones, and . Stack them in a matrix . Then , exactly lesson 3.5.3's least squares, solved with lstsq.
Lesson 4.4.1 showed that this is a quadratic in with Hessian . The three columns are independent, so is positive definite and the normal equations give the one global minimum.
With , and . The residuals, quote minus fit, are in bp:
| maturity | 0.5 | 1 | 2 | 3 | 5 | 7 | 10 | 15 | 20 | 30 |
|---|---|---|---|---|---|---|---|---|---|---|
| residual (bp) | 1.75 | 2.57 | 0.87 | 0.26 | 2.36 | 0.45 |
The 7-year quote is missed by 5.53 bp. The Hessian's eigenvalues are 0.1128, 1.3376 and 24.0792, all positive.
Try it
design builds for a given . The cell fits and prints , and the residuals in bp.
The last line prints the eigenvalues of and the gradient , which is zero at the fit.
With τ free: a nonlinear problem
sits inside the exponentials, so is not a quadratic in all four numbers, and is not a linear system. This is the problem lessons 4.4.2 and 4.4.3 were built for.
The profile: one τ at a time
Each has its own best , from lstsq. Call the sum of squares it leaves , the profile. The lowest point of the profile is the lowest point of over all four numbers.
| 0.25 | 0.32 | 0.5 | 0.69 | 1 | 1.63 | 2 | 5 | 10 | |
|---|---|---|---|---|---|---|---|---|---|
| 0.00847 | 0.007409 | 0.010074 | 0.011819 | 0.008789 | 0.002374 | 0.005053 | 0.11154 | 0.263248 |
The profile has two valleys: a local minimum near and the global one near , with a hump near between them. These are lesson 4.4.1's local and global minima, met in a real problem.
The best sum of squares for each time scale . Two valleys, separated by a hump: a search that starts left of the hump can end in the worse one.
A grid search tries , a linear fit at each, and keeps the best. It finds , with and . It cannot be fooled by the second valley, so many desks fit this way; the exercise asks you to write it.
Try it
profile fits for one and returns it with . The loop is the table.
The grid search runs profile at 991 values of and keeps the one with the smallest .
All four numbers at once
To polish the grid's answer, treat all four numbers as one point and minimise . Its gradient is .
The partial derivatives of in the three s are 1, and . The one in comes from the chain rule (lesson 2.3.3), as in the code.
The cells take the Hessian by central differences of the gradient (lesson 4.1.2), with a gap of . Two Newton steps from the grid's answer reach the minimum:
There the Hessian has eigenvalues 0.0196, 0.1796, 1.5573 and 24.0767. All are positive, so this is a strict local minimum (lesson 4.4.1), and the profile shows it is the global one. Their ratio, the condition number, is about 1229: a long narrow valley (lesson 4.4.2).
Gradient descent must keep below . From the fit with , it is still 0.3839 from the minimum after 1,000 steps. It needs 5,000 steps to come within 0.0009.
Plain Newton from the same fit goes badly. There the Hessian has an eigenvalue of , so it is not positive definite (lesson 4.4.3). The step lands on , a negative time scale with no meaning.
Started from the grid's answer, where the Hessian is positive definite, Newton takes two steps.
Try it
grad is the gradient of , with the column from the chain rule; hess takes central differences of it. Two Newton steps start from the grid's and its .
The last line prints the eigenvalues of the Hessian, the condition number and the step-size limit for gradient descent.
Try it
The cell repeats the functions, finds the minimum again by the two Newton steps, and starts from the fit.
Gradient descent with prints its distance from the minimum after 1,000 and 5,000 steps. The plain Newton step from the same start prints the Hessian's eigenvalues and where it lands.
scipy.optimize.minimize, and the other valley
minimize(sse, start) (lesson 4.4.3) runs BFGS on all four numbers. From the start it takes 20 iterations and returns the best fit.
From it takes 36 iterations, reports success, and returns a different curve. The counts can differ slightly with the SciPy version.
| best fit | other valley | |
|---|---|---|
| 1.6271 | 0.3224 | |
| 1.5273 | ||
| 0.002374 | 0.007408 | |
| largest miss (bp) | 3.44, at 7 years | 5.57, at 5 years |
| root-mean-square miss (bp) | 1.54 | 2.72 |
| 4-year rate | 4.3396% | 4.3155% |
| short end, | 2.7457% | 3.606% |
The other fit is the profile's local minimum near . Its is 3.12 times larger, and its is negative: a dip near the short end instead of a hump. The two curves are close across the quotes but part company before the first one, at 6 months.
The best fit and the fit in the other valley. Across the quotes they differ by a few basis points; before the first quote they start from 2.75% and 3.61%.
Parameters against the curve. The quotes came from and , yet the best fit has . Its curve still stays within 3.48 bp of the true curve from 3 months to 30 years.
Noise moves the parameters easily along the flat directions of , those with small Hessian eigenvalues, while the curve barely moves. So read the curve, not the individual numbers.
Try it
minimize runs from the two starts. Each line prints the iterations, success, the parameters, and the residuals in bp.
Then come each fit's 4-year rate and short end. The last line is the largest gap between the best fit and the true curve, on a fine grid of maturities.
On the desk
Try it
The first minimize runs on the quotes in decimals. Then the fit at , with the condition number of .
Last, the typo in the 7-year quote. The line prints the refit's and , its 7-year residual, and how far the fitted 5- and 10-year rates move, in bp.
Fit a Nelson–Siegel curve
Write two functions, with NumPy.
ns_curve(maturities, beta, tau)returns the Nelson–Siegel rates at the given maturities, as a NumPy array, forbetaand the time scaletau. RaiseValueErrorunlesstauand every maturity are positive.fit_nelson_siegel(maturities, yields, taus)fits bylstsqat each intaus, as in the grid search above. It returns(beta, tau, sse)for the with the smallest sum of squared errors.
In fit_nelson_siegel, raise ValueError for bad inputs:
- maturities and yields of different lengths, or fewer than 4 quotes;
- a missing or infinite value, or a maturity that is not positive;
- an empty
taus, or a value in it that is not positive.
The tests use this case study's quotes, a curve that fits exactly, and a grid that stops short of the best valley. Others try each kind of bad input.
See the tests
import numpy as np
MATURITIES = np.array([0.5, 1, 2, 3, 5, 7, 10, 15, 20, 30], dtype=float)
QUOTES = np.array([3.2, 3.517, 3.953, 4.213, 4.438, 4.46, 4.538, 4.559, 4.578, 4.556])
GRID = np.round(np.arange(0.1, 10.0001, 0.01), 2)
def _expect_value_error(call, why):
try:
call()
except ValueError:
return
raise AssertionError(f"expected ValueError: {why}")
def test_curve_values():
"""ns_curve with beta = (4, −1, 1) and tau = 2 gives 3.3935 at 1 year and 3.9933 at 10 years"""
got = ns_curve([1.0, 10.0], (4.0, -1.0, 1.0), 2.0)
expect_close(got, np.array([3.393469340287367, 3.9932620530009144]), what="ns_curve([1, 10], (4, -1, 1), 2)")
def test_curve_long_end():
"""Far out, the curve tends to beta0: 4.0 at a million years"""
expect_close(ns_curve([1e6], (4.0, -1.0, 1.0), 2.0), np.array([4.0]), what="ns_curve at 1,000,000 years", rel=1e-5)
def test_case_study_fit():
"""The case study's quotes on the grid 0.1, 0.11, ..., 10 give tau = 1.63 and S = 0.002374"""
beta, tau, sse = fit_nelson_siegel(MATURITIES, QUOTES, GRID)
expect_close(tau, 1.63, what="tau")
expect_close(beta, np.array([4.586793133757306, -1.8404383921777805, 1.529770785577442]), what="beta", rel=1e-6)
expect_close(sse, 0.0023738896016146332, what="the sum of squared errors", rel=1e-6)
def test_return_types():
"""It returns beta as a NumPy array of 3 numbers, then tau and the sum of squares as numbers"""
beta, tau, sse = fit_nelson_siegel(MATURITIES, QUOTES, [1.0, 2.0])
assert isinstance(beta, np.ndarray) and beta.shape == (3,), f"beta should be an array of 3 numbers, got {beta!r}"
assert np.isscalar(tau) and np.isscalar(sse), f"tau and sse should be numbers, got {tau!r} and {sse!r}"
def test_exact_curve():
"""Quotes made from beta = (5, −2, 1) and tau = 3, with 3 on the grid, are fitted exactly"""
m = np.array([0.25, 1, 2, 4, 6, 8, 12, 25])
u = m / 3.0
g1 = (1 - np.exp(-u)) / u
y = 5.0 - 2.0 * g1 + 1.0 * (g1 - np.exp(-u))
beta, tau, sse = fit_nelson_siegel(m, y, [1.0, 2.0, 3.0, 4.0])
expect_close(tau, 3.0, what="tau")
expect_close(beta, np.array([5.0, -2.0, 1.0]), what="beta")
expect_close(sse, 0.0, what="the sum of squared errors", abs_tol=1e-12)
def test_grid_decides_the_valley():
"""A grid that stops below 0.6 finds the other valley: tau = 0.32 and S = 0.007409"""
beta, tau, sse = fit_nelson_siegel(MATURITIES, QUOTES, np.round(np.arange(0.1, 0.6, 0.01), 2))
expect_close(tau, 0.32, what="tau")
expect_close(sse, 0.007409477119638619, what="the sum of squared errors", rel=1e-6)
def test_lists_work():
"""Plain Python lists work as inputs"""
beta, tau, sse = fit_nelson_siegel(MATURITIES.tolist(), QUOTES.tolist(), [1.63])
expect_close(sse, 0.0023738896016146332, what="the sum of squared errors at tau = 1.63", rel=1e-6)
def test_lengths_differ():
"""Ten maturities and nine yields: ValueError"""
_expect_value_error(lambda: fit_nelson_siegel(MATURITIES, QUOTES[:9], GRID), "the inputs have different lengths")
def test_too_few_quotes():
"""Three quotes cannot fix four parameters: ValueError"""
_expect_value_error(lambda: fit_nelson_siegel(MATURITIES[:3], QUOTES[:3], GRID), "3 quotes for 4 parameters")
def test_missing_quote():
"""A missing quote (nan) is refused: ValueError"""
bad = QUOTES.copy()
bad[4] = np.nan
_expect_value_error(lambda: fit_nelson_siegel(MATURITIES, bad, GRID), "the 5-year quote is nan")
def test_bad_maturity_or_tau():
"""A zero maturity, a negative tau or an empty grid: ValueError"""
zero = MATURITIES.copy()
zero[0] = 0.0
_expect_value_error(lambda: fit_nelson_siegel(zero, QUOTES, GRID), "a maturity of 0 divides by zero in g1")
_expect_value_error(lambda: fit_nelson_siegel(MATURITIES, QUOTES, [1.0, -2.0]), "tau = -2 is not positive")
_expect_value_error(lambda: fit_nelson_siegel(MATURITIES, QUOTES, []), "the grid of taus is empty")
Summary card
- The Nelson–Siegel curve has a level , a short end , a hump peaking near , and a time scale .
- With fixed, the fit is linear least squares (lesson 3.5.3). is a quadratic in with positive definite Hessian , so
lstsqgives its one minimum. - With free, is nonlinear. The profile here has two valleys, at and . A grid of , with a linear fit at each, finds the deeper one.
- At the minimum the Hessian's condition number is about 1229, so gradient descent needs thousands of steps. Newton from the grid's answer takes two; from it meets an indefinite Hessian and jumps to a negative .
minimizereturns a local minimum near its start and reports success there; compare across starts.- On the desk: work in percent or bp, keep in a fixed range, and check quotes before fitting. Treat the curve beyond the quotes with care, and read the curve, not the parameters.