SigmaField
Mathematical Foundations for Quant Finance
0 of 256 lessons
      1. 4.4.1 Optimality conditions in several variablesConcept · 30 min
      2. 4.4.2 Gradient descentConcept · 30 min
      3. 4.4.3 Newton's method in several variablesConcept · 30 min
      4. 4.4.4 Practice set: unconstrained optimisationPractice set · 25 min
      5. 4.4.5 Case study: fitting a curve by least squaresCase study · 35 min

Level 4, Multivariable calculus and optimisation 4.4 Unconstrained optimisation Lesson 5 of 5

Case studyLesson 4.4.5 in Level 4, Multivariable calculus and optimisation. About 35 minutes.

Case study: fitting a curve by least squares

The Nelson–Siegel curve

Write y(m)y(m) for the zero rate, in percent, of maturity mm years (case study 1.6.4). The Nelson–Siegel curve builds it from three shapes:

Rule: The Nelson–Siegel curve

y(m)=β0+β1 g1 ⁣(mτ)+β2 g2 ⁣(mτ)g1(u)=1−e−uug2(u)=g1(u)−e−u\begin{gathered} y(m) = \beta_0 + \beta_1\,g_1\!\left(\tfrac{m}{\tau}\right) + \beta_2\,g_2\!\left(\tfrac{m}{\tau}\right) \\[6pt] g_1(u) = \frac{1 - e^{-u}}{u} \\[6pt] g_2(u) = g_1(u) - e^{-u} \end{gathered}

β0\beta_0 is the level far out: as mm grows, g1g_1 and g2g_2 fall to 0. At the short end g1g_1 tends to 1 and g2g_2 to 0, so yy tends to β0+β1\beta_0 + \beta_1.

β2\beta_2 adds a hump. The time scale τ>0\tau > 0, in years, sets where the shapes bend.

g2g_2 is largest at u=1.79u = 1.79, where it is 0.2984, so the hump peaks at a maturity of about 1.79τ1.79\tau. With τ=2\tau = 2:

maturity mm0.51251030
g1(m/2)g_1(m/2)0.88480.78690.63210.36720.19870.0667
g2(m/2)g_2(m/2)0.1060.18040.26420.28510.19190.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, g1g_1 falling from 1 towards 0, and the hump g2g_2.

The quotes

The ten quotes are invented for illustration. They come from a Nelson–Siegel curve with β=(4.6,−1.8,1.5)\boldsymbol{\beta} = (4.6, -1.8, 1.5) and τ=1.8\tau = 1.8, 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.51235710152030
zero rate (%)3.23.5173.9534.2134.4384.464.5384.5594.5784.556

The fit chooses β=(β0,β1,β2)\boldsymbol{\beta} = (\beta_0, \beta_1, \beta_2) and τ\tau to make the sum of squared errors smallest:

S(β,τ)=∑i=110(yi−y(mi))2S(\boldsymbol{\beta}, \tau) = \sum_{i=1}^{10} \big(y_i - y(m_i)\big)^2

Here yiy_i is the quote at maturity mim_i, and SS is in squared percentage points. An SS 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 g1g_1 and g2g_2 for τ=2\tau = 2, the table above. The last line finds the peak of g2g_2 on a fine grid.

⌘/Ctrl + Enter runs the cell

With τ fixed: linear least squares

Fix τ\tau, and the curve is a combination of three known columns: a column of ones, g1(mi/τ)g_1(m_i/\tau) and g2(mi/τ)g_2(m_i/\tau). Stack them in a 10×310 \times 3 matrix XX. Then S=∥y−Xβ∥2S = \lVert\mathbf{y} - X\boldsymbol{\beta}\rVert^2, exactly lesson 3.5.3's least squares, solved with lstsq.

Lesson 4.4.1 showed that this SS is a quadratic in β\boldsymbol{\beta} with Hessian 2X⊤X2X^\top X. The three columns are independent, so 2X⊤X2X^\top X is positive definite and the normal equations give the one global minimum.

With τ=2\tau = 2, β=(4.5457,−1.7217,1.8093)\boldsymbol{\beta} = (4.5457, -1.7217, 1.8093) and S=0.005053S = 0.005053. The residuals, quote minus fit, are in bp:

maturity0.51235710152030
residual (bp)−1.41-1.41−0.02-0.021.752.570.87−5.53-5.53−1.29-1.290.262.360.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 XX for a given τ\tau. The cell fits τ=2\tau = 2 and prints β\boldsymbol{\beta}, SS and the residuals in bp.

The last line prints the eigenvalues of 2X⊤X2X^\top X and the gradient 2X⊤(Xβ−y)2X^\top(X\boldsymbol{\beta} - \mathbf{y}), which is zero at the fit.

⌘/Ctrl + Enter runs the cell

With τ free: a nonlinear problem

τ\tau sits inside the exponentials, so SS is not a quadratic in all four numbers, and ∇S=0\nabla S = \mathbf{0} 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 τ\tau has its own best β\boldsymbol{\beta}, from lstsq. Call the sum of squares it leaves S∗(τ)S^*(\tau), the profile. The lowest point of the profile is the lowest point of SS over all four numbers.

τ\tau0.250.320.50.6911.632510
S∗(τ)S^*(\tau)0.008470.0074090.0100740.0118190.0087890.0023740.0050530.111540.263248

The profile has two valleys: a local minimum near τ=0.32\tau = 0.32 and the global one near τ=1.63\tau = 1.63, with a hump near τ=0.69\tau = 0.69 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 τ\tau. Two valleys, separated by a hump: a search that starts left of the hump can end in the worse one.

A grid search tries τ=0.1,0.11,…,10\tau = 0.1, 0.11, \ldots, 10, a linear fit at each, and keeps the best. It finds τ=1.63\tau = 1.63, with β=(4.5868,−1.8404,1.5298)\boldsymbol{\beta} = (4.5868, -1.8404, 1.5298) and S=0.002374S = 0.002374. 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 β\boldsymbol{\beta} for one τ\tau and returns it with S∗(τ)S^*(\tau). The loop is the table.

The grid search runs profile at 991 values of τ\tau and keeps the one with the smallest S∗S^*.

⌘/Ctrl + Enter runs the cell

All four numbers at once

To polish the grid's answer, treat all four numbers as one point p=(β0,β1,β2,τ)\mathbf{p} = (\beta_0, \beta_1, \beta_2, \tau) and minimise S(p)S(\mathbf{p}). Its gradient is 2∑i(y(mi)−yi) ∇y(mi)2\sum_i (y(m_i) - y_i)\,\nabla y(m_i).

The partial derivatives of y(m)y(m) in the three β\betas are 1, g1g_1 and g2g_2. The one in τ\tau 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 10−510^{-5}. Two Newton steps from the grid's answer reach the minimum:

β=(4.5871,−1.8414,1.5273)τ=1.6271,S=0.00237373\begin{gathered} \boldsymbol{\beta} = (4.5871, -1.8414, 1.5273) \\ \tau = 1.6271, \quad S = 0.00237373 \end{gathered}

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 η\eta below 2/24.0767=0.08312/24.0767 = 0.0831. From the τ=1\tau = 1 fit with η=0.08\eta = 0.08, 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 τ=1\tau = 1 fit goes badly. There the Hessian has an eigenvalue of −0.0028-0.0028, so it is not positive definite (lesson 4.4.3). The step lands on τ=−0.3158\tau = -0.3158, 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 SS, with the τ\tau column from the chain rule; hess takes central differences of it. Two Newton steps start from the grid's τ=1.63\tau = 1.63 and its β\boldsymbol{\beta}.

The last line prints the eigenvalues of the Hessian, the condition number and the step-size limit for gradient descent.

⌘/Ctrl + Enter runs the cell

Try it

The cell repeats the functions, finds the minimum again by the two Newton steps, and starts from the τ=1\tau = 1 fit.

Gradient descent with η=0.08\eta = 0.08 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.

⌘/Ctrl + Enter runs the cell

scipy.optimize.minimize, and the other valley

minimize(sse, start) (lesson 4.4.3) runs BFGS on all four numbers. From the start (4,−1,1,1)(4, -1, 1, 1) it takes 20 iterations and returns the best fit.

From (5,0,0,1)(5, 0, 0, 1) it takes 36 iterations, reports success, and returns a different curve. The counts can differ slightly with the SciPy version.

best fitother valley
τ\tau1.62710.3224
β2\beta_21.5273−3.1042-3.1042
SS0.0023740.007408
largest miss (bp)3.44, at 7 years5.57, at 5 years
root-mean-square miss (bp)1.542.72
4-year rate4.3396%4.3155%
short end, β0+β1\beta_0 + \beta_12.7457%3.606%

The other fit is the profile's local minimum near τ=0.32\tau = 0.32. Its SS is 3.12 times larger, and its β2\beta_2 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 τ=1.8\tau = 1.8 and β=(4.6,−1.8,1.5)\boldsymbol{\beta} = (4.6, -1.8, 1.5), yet the best fit has τ=1.6271\tau = 1.6271. 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 SS, 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, SS 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.

⌘/Ctrl + Enter runs the cell
Check yourself
From the start (5,0,0,1)(5, 0, 0, 1), minimize reports success with τ=0.32\tau = 0.32 and S=0.007408S = 0.007408. What should the desk conclude?

On the desk

Try it

The first minimize runs on the quotes in decimals. Then the fit at τ=100\tau = 100, with the condition number of XX.

Last, the typo in the 7-year quote. The line prints the refit's SS and τ\tau, its 7-year residual, and how far the fitted 5- and 10-year rates move, in bp.

⌘/Ctrl + Enter runs the cell
Check yourself
Why does minimize stop after 3 iterations, with τ\tau still at 1.0001, when the quotes are typed as decimals?
Exercise

Fit a Nelson–Siegel curve

Write two functions, with NumPy.

  1. ns_curve(maturities, beta, tau) returns the Nelson–Siegel rates at the given maturities, as a NumPy array, for beta =(β0,β1,β2)= (\beta_0, \beta_1, \beta_2) and the time scale tau. Raise ValueError unless tau and every maturity are positive.
  2. fit_nelson_siegel(maturities, yields, taus) fits β\boldsymbol{\beta} by lstsq at each τ\tau in taus, as in the grid search above. It returns (beta, tau, sse) for the τ\tau 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 y(m)=β0+β1g1(m/τ)+β2g2(m/τ)y(m) = \beta_0 + \beta_1 g_1(m/\tau) + \beta_2 g_2(m/\tau) has a level β0\beta_0, a short end β0+β1\beta_0 + \beta_1, a hump β2\beta_2 peaking near 1.79τ1.79\tau, and a time scale τ\tau.
  • With τ\tau fixed, the fit is linear least squares (lesson 3.5.3). SS is a quadratic in β\boldsymbol{\beta} with positive definite Hessian 2X⊤X2X^\top X, so lstsq gives its one minimum.
  • With τ\tau free, SS is nonlinear. The profile S∗(τ)S^*(\tau) here has two valleys, at τ=0.32\tau = 0.32 and τ=1.63\tau = 1.63. A grid of τ\tau, 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 τ=1\tau = 1 it meets an indefinite Hessian and jumps to a negative τ\tau.
  • minimize returns a local minimum near its start and reports success there; compare SS across starts.
  • On the desk: work in percent or bp, keep τ\tau in a fixed range, and check quotes before fitting. Treat the curve beyond the quotes with care, and read the curve, not the parameters.

Free further reading

Optional. This lesson covers what you need; these add more examples and practice. All are free to read.