Dynamic factor analysis of the yield curve (DK §8.6)#
The dynamic Nelson-Siegel model (Nelson and Siegel 1987; Diebold and Li 2006), a dynamic factor model (DK §3.7):
with level, slope and curvature factors and the Nelson-Siegel loadings for
each maturity. The model is written as an extension of ssm_model_t,
the way to define models that the components do not cover.
Data#
DK use the yields of Diebold and Li, 17 maturities from licensed data. This
example uses the public-domain constant-maturity yields of the Federal
Reserve for the same months, January 1985 to December 2000, at 8
maturities (data/us_yields.csv), so its estimates differ from DK’s.
Results#
The estimated decay \(\hat\lambda = 0.079\) is close to DK’s 0.078, and the persistence of the factors, \(\hat\Phi_{11} = 0.994\) and \(\hat\Phi_{22} = 0.941\), to DK’s 0.994 and 0.939. Collapsing the 8 observations to the 3 factors (DK §6.5), as DK do, gives the same log likelihood and smoothed factors to 1e-12.
Program#
!> DK 8.6: dynamic Nelson-Siegel model for the yield curve (Nelson and
!> Siegel 1987; Diebold and Li 2006), a dynamic factor model (DK 3.7):
!>
!> y_t = Lambda f_t + eps_t, eps_t ~ N(0, sigma2 I_N),
!> f_t+1 = Phi f_t + eta_t, eta_t ~ N(0, Sigma_eta),
!>
!> with f_t = (level, slope, curvature) and the Nelson-Siegel loadings for
!> maturity tau_i (months): x_i1 = 1, x_i2 = (1 - z_i) / (lambda tau_i),
!> x_i3 = x_i2 - z_i, z_i = exp(-lambda tau_i). The parameters lambda,
!> sigma2, Phi and Sigma_eta are estimated by maximum likelihood. The
!> factors are initialized as diffuse. As in DK, the observation vector can
!> be collapsed to the 3 factors (DK 6.5); the example checks that this
!> gives the same log likelihood and smoothed factors.
!>
!> The model is written here by extending `ssm_model_t`, the way to define
!> a model that the built-in components don't cover.
!>
!> DK use the Diebold-Li data (17 maturities from the CRSP bond files,
!> which are licensed) and report lambda = 0.078, sigma2 = 0.014,
!> Phi = [0.994 0.029 -0.022; -0.029 0.939 0.040; 0.025 0.023 0.841],
!> Sigma_eta = [0.095 -0.014 0.044; 0.383 0.009; 0.799]. This example uses the
!> public-domain FRED constant-maturity yields for the same months (8
!> maturities, see data/README.md), so its estimates differ.
!>
!> Run from the repo root: fpm run --example dk_8_6_yield_curve
module dynamic_nelson_siegel
use statespace
implicit none
private
public :: dns_t, dns_model
!> params = [lambda, sigma2, Phi (column-major, 9), lower triangle of
!> Sigma_eta by columns (6)].
type, extends(ssm_model_t) :: dns_t
real(dp), allocatable :: tau(:)
contains
procedure :: update
procedure :: start_params
procedure :: transform_params
procedure :: untransform_params
procedure :: param_names
end type dns_t
contains
function dns_model(y, tau) result(model)
real(dp), intent(in) :: y(:, :), tau(:)
type(dns_t) :: model
model%tau = tau
model%k_params = 17
model%rep = ssm_rep(y, m=3, r=3)
model%rep%R(:, :, 1) = eye(3)
call model%rep%initialize_diffuse()
call model%update(model%start_params())
end function dns_model
!> Nelson-Siegel loadings (N x 3).
pure function loadings(tau, lambda) result(Z)
real(dp), intent(in) :: tau(:), lambda
real(dp) :: Z(size(tau), 3)
real(dp) :: zi(size(tau))
zi = exp(-lambda * tau)
Z(:, 1) = 1.0_dp
Z(:, 2) = (1.0_dp - zi) / (lambda * tau)
Z(:, 3) = Z(:, 2) - zi
end function loadings
subroutine update(self, params)
class(dns_t), intent(inout) :: self
real(dp), intent(in) :: params(:)
self%rep%Z(:, :, 1) = loadings(self%tau, params(1))
self%rep%H(:, :, 1) = params(2) * eye(size(self%tau))
self%rep%T(:, :, 1) = reshape(params(3:11), [3, 3])
self%rep%Q(:, :, 1) = lower_to_sym(params(12:17))
end subroutine update
function start_params(self) result(params)
class(dns_t), intent(in) :: self
real(dp), allocatable :: params(:)
! Diebold and Li's lambda = 0.0609 per month
params = [0.0609_dp, 0.01_dp, pack(0.95_dp * eye(3), .true.), &
0.1_dp, 0.0_dp, 0.0_dp, 0.1_dp, 0.0_dp, 0.1_dp]
end function start_params
!> lambda = exp(x), sigma2 = exp(2 x) (DK 7.3.2), Phi unrestricted,
!> Sigma_eta = L L' with L lower triangular and positive diagonal exp(x).
function transform_params(self, unconstrained) result(constrained)
class(dns_t), intent(in) :: self
real(dp), intent(in) :: unconstrained(:)
real(dp), allocatable :: constrained(:)
real(dp) :: L(3, 3)
L = lower_to_mat(unconstrained(12:17))
L(1, 1) = exp(L(1, 1)); L(2, 2) = exp(L(2, 2)); L(3, 3) = exp(L(3, 3))
constrained = [exp(unconstrained(1)), constrain_positive(unconstrained(2)), &
unconstrained(3:11), sym_to_lower(matmul(L, transpose(L)))]
end function transform_params
function untransform_params(self, constrained) result(unconstrained)
class(dns_t), intent(in) :: self
real(dp), intent(in) :: constrained(:)
real(dp), allocatable :: unconstrained(:)
real(dp) :: L(3, 3)
L = cholesky3(lower_to_sym(constrained(12:17)))
L(1, 1) = log(L(1, 1)); L(2, 2) = log(L(2, 2)); L(3, 3) = log(L(3, 3))
unconstrained = [log(constrained(1)), unconstrain_positive(constrained(2)), &
constrained(3:11), sym_to_lower(L)]
end function untransform_params
function param_names(self) result(names)
class(dns_t), intent(in) :: self
character(len=32), allocatable :: names(:)
integer :: i, j
names = [character(len=32) :: "lambda", "sigma2"]
do j = 1, 3
do i = 1, 3
names = [character(len=32) :: names, "phi."//achar(48 + i)//achar(48 + j)]
end do
end do
do j = 1, 3
do i = j, 3
names = [character(len=32) :: names, "sigma_eta."//achar(48 + i)//achar(48 + j)]
end do
end do
end function param_names
!> The 3 x 3 lower triangular matrix with lower triangle v (by columns).
pure function lower_to_mat(v) result(L)
real(dp), intent(in) :: v(6)
real(dp) :: L(3, 3)
L = 0.0_dp
L(1:3, 1) = v(1:3)
L(2:3, 2) = v(4:5)
L(3, 3) = v(6)
end function lower_to_mat
pure function lower_to_sym(v) result(S)
real(dp), intent(in) :: v(6)
real(dp) :: S(3, 3)
S = lower_to_mat(v)
S = S + transpose(S)
S(1, 1) = v(1); S(2, 2) = v(4); S(3, 3) = v(6)
end function lower_to_sym
pure function sym_to_lower(S) result(v)
real(dp), intent(in) :: S(3, 3)
real(dp) :: v(6)
v = [S(1:3, 1), S(2:3, 2), S(3, 3)]
end function sym_to_lower
pure function cholesky3(S) result(L)
real(dp), intent(in) :: S(3, 3)
real(dp) :: L(3, 3)
integer :: i, j
L = 0.0_dp
do j = 1, 3
L(j, j) = sqrt(S(j, j) - sum(L(j, 1:j - 1)**2))
do i = j + 1, 3
L(i, j) = (S(i, j) - sum(L(i, 1:j - 1) * L(j, 1:j - 1))) / L(j, j)
end do
end do
end function cholesky3
end module dynamic_nelson_siegel
program dk_8_6_yield_curve
use statespace
use csv_io, only: read_csv, column
use dynamic_nelson_siegel, only: dns_t, dns_model
implicit none
real(dp), parameter :: tau(8) = [3, 6, 12, 24, 36, 60, 84, 120]
character(len=3), parameter :: col(8) = ["3 ", "6 ", "12 ", "24 ", "36 ", "60 ", &
"84 ", "120"]
character(len=32), allocatable :: names(:)
real(dp), allocatable :: data(:, :), y(:, :), adjust(:), Phi(:, :)
type(dns_t) :: model
type(fit_result_t) :: res
type(fit_options_t) :: opts
type(filter_result_t) :: fres, cfres
type(smoother_result_t) :: sres, csres
type(ssm_rep_t) :: crep
integer :: info, i, t, n
call read_csv("data/us_yields.csv", names, data)
n = size(data, 1)
allocate (y(size(tau), n))
do i = 1, size(tau)
y(i, :) = column(names, data, "m"//trim(col(i)))
end do
model = dns_model(y, tau)
opts%factr = 1.0e3_dp
opts%pgtol = 1.0e-7_dp
opts%maxiter = 2000
call fit(model, res, options=opts, info=info)
if (info /= SS_OK .and. info /= SS_ERR_NOT_PD) error stop "fit"
print '(a)', trim(res%message)
print '(a, f10.4, a, f10.5)', "lambda: ", res%params(1), " sigma2: ", res%params(2)
Phi = reshape(res%params(3:11), [3, 3])
print '(a)', "Phi:"
print '(3f9.3)', (Phi(i, :), i=1, 3)
print '(a)', "Sigma_eta (lower triangle):"
print '(f9.3)', res%params(12)
print '(2f9.3)', res%params(13), res%params(15)
print '(3f9.3)', res%params(14), res%params(16), res%params(17)
print '(a, f12.3)', "log likelihood: ", res%llf
! The collapsed model (DK 6.5) gives the same likelihood and factors
call model%smooth(res%params, fres, sres, info)
allocate (adjust(n))
call collapse_observations(model%rep, crep, adjust, info)
if (info /= SS_OK) error stop "collapse"
call kalman_filter(crep, cfres, info)
call state_smoother(crep, cfres, csres, info)
print '(a, es10.2)', "collapsed log likelihood - full: ", &
cfres%llf + sum(adjust) - fres%llf
print '(a, es10.2)', "largest smoothed factor difference: ", &
maxval(abs(csres%alphahat - sres%alphahat))
! Smoothed factors against the data proxies of DK Fig. 8.12
print '(/, a6, 6a10)', "month", "level", "10y", "slope", "3m-10y", "curv", "proxy"
do t = 1, n, 24
print '(i6, 6f10.3)', t, sres%alphahat(1, t), y(8, t), sres%alphahat(2, t), &
y(1, t) - y(8, t), sres%alphahat(3, t), 2 * y(4, t) - y(1, t) - y(8, t)
end do
end program dk_8_6_yield_curve
Output#
CONVERGENCE: REL_REDUCTION_OF_F_<=_FACTR*EPSMCH
lambda: 0.0789 sigma2: 0.00351
Phi:
0.994 -0.002 -0.003
-0.007 0.941 0.066
-0.011 -0.026 0.963
Sigma_eta (lower triangle):
0.065
-0.050 0.069
0.070 -0.040 0.244
log likelihood: 1348.475
collapsed log likelihood - full: 6.82E-13
largest smoothed factor difference: 1.65E-12
month level 10y slope 3m-10y curv proxy
1 11.923 11.380 -4.290 -3.360 -0.358 0.460
25 7.392 7.080 -1.869 -1.500 -1.358 -0.200
49 8.969 9.090 -0.575 -0.530 1.628 0.710
73 8.495 8.090 -2.103 -1.680 -1.615 -0.240
97 7.489 6.600 -4.530 -3.530 -3.763 -0.890
121 7.736 7.780 -2.203 -1.880 2.542 1.340
145 6.693 6.580 -1.752 -1.410 0.224 0.270
169 4.822 4.720 -0.355 -0.270 -0.312 0.070