SigmaField
Mathematical Foundations for Quant Finance
0 of 256 lessons
      1. 3.8.1 The singular value decompositionConcept · 35 min
      2. 3.8.2 Low-rank approximationConcept · 25 min
      3. 3.8.3 Practice set: the singular value decompositionPractice set · 20 min
      4. 3.8.4 Case study: compressing a factor modelCase study · 35 min

Level 3, Linear algebra 3.8 Singular value decomposition (optional) Lesson 4 of 4

Case studyLesson 3.8.4 in Level 3, Linear algebra. About 35 minutes.

Case study: compressing a factor model

The returns matrix

The returns are invented for illustration, not market data. They come from a factor model. Each month a few common moves hit every asset, each in its own proportion, and each asset adds noise of its own.

  • A market move, with a monthly volatility of 4.5%, that every asset follows with a loading between 0.7 and 1.3.
  • A move of 3% that lifts the first ten assets and pushes the last ten down.
  • A third move of 3%, with loadings between −1-1 and 1.
  • Each asset's own noise, with a volatility between 2% and 3.5% a month.

The cell draws them with np.random.default_rng(38). As in case study 3.2.5, the returns sit in a matrix XX with one row per month and one column per asset. Here XX is 120×20120 \times 20, in monthly decimals.

Centre first. Subtract each column's mean from that column, as case study 3.1.4 did before measuring angles. Call the result XcX_c. Every column now averages 0, and only the moves around the mean are left.

The sample covariance matrix of case study 3.2.5 is then a single product, C=1n−1Xc⊤XcC = \frac{1}{n - 1}X_c^\top X_c, with n=120n = 120 months. Entry (i,j)(i, j) is the dot product of centred columns ii and jj, over n−1n - 1. Case study 3.2.5 wrote it SS; in this module SS holds singular values, so it is CC here.

Try it

returns() builds the invented data; the same function opens every cell below. X.mean(axis=0) holds the 20 column means, between −0.39%-0.39\% and 1.49% a month in this sample.

The last line checks that every centred column averages 0 and that Xc⊤Xc/(n−1)X_c^\top X_c / (n - 1) is what np.cov computes.

⌘/Ctrl + Enter runs the cell

The SVD of the centred returns

Take the singular value decomposition Xc=USV⊤X_c = USV^\top (lesson 3.8.1). With full_matrices=False, UU is 120×20120 \times 20, there are 20 singular values, and V⊤V^\top is 20×2020 \times 20.

Put it into CC. Since U⊤U=IU^\top U = I, C=1n−1VS⊤U⊤USV⊤=V S2n−1 V⊤C = \frac{1}{n - 1}VS^\top U^\top USV^\top = V\,\frac{S^2}{n - 1}\,V^\top. That is the spectral decomposition of CC (lesson 3.6.3), with VV in the place of QQ.

Rule: Principal components from the SVD

Xc=USV⊤C=V S2n−1 V⊤λi=σi2n−1\begin{gathered} X_c = USV^\top \\[4pt] C = V\,\frac{S^2}{n - 1}\,V^\top \\[4pt] \lambda_i = \frac{\sigma_i^2}{n - 1} \end{gathered}

The right singular vectors vi\mathbf{v}_i of the centred returns are the eigenvectors of their covariance matrix: the principal components of case study 3.6.5. Each eigenvalue λi\lambda_i, the variance along vi\mathbf{v}_i, is σi2/(n−1)\sigma_i^2 / (n - 1).

So the SVD finds the principal components without forming CC at all. Here the three largest variances are 0.0376, 0.0091 and 0.0046 a month, out of a total of 0.0645.

It also gives each component's history: Xcvi=σiuiX_c\mathbf{v}_i = \sigma_i\mathbf{u}_i (lesson 3.8.1) holds the 120 monthly returns of the "portfolio" with holdings vi\mathbf{v}_i.

The share of the total variance along vi\mathbf{v}_i is λi\lambda_i over the trace of CC: the sum of the 20 variances, which equals the sum of the eigenvalues (lesson 3.6.1). The factor n−1n - 1 cancels, so it is σi2/∑jσj2\sigma_i^2 / \sum_j \sigma_j^2: the share of lesson 3.8.2.

Try it

eigh lists eigenvalues smallest first, so [::-1] reverses them. They match σi2/(n−1)\sigma_i^2/(n - 1), and the eigenvectors match the rows of Vt once both have their signs fixed. Here each vector's entries are made to add up to a positive number.

The last lines print the three largest variances, the trace and the first five shares in percent.

⌘/Ctrl + Enter runs the cell

How many factors?

Three components stand clear of a floor of 17 small ones. Taking those three out of the returns removes almost all of the correlation between the assets.

The first three components carry 58.33%, 14.10% and 7.06% of the variance, together 79.49%. After them comes a floor: 2.14%, 2.06%, 1.97% and so on, falling slowly to 0.44%.

The gap shows in the ratios of neighbouring singular values (lesson 3.8.2). σ3/σ4=1.815\sigma_3/\sigma_4 = 1.815, while every later ratio lies between 1.004 and 1.103. So the data suggest k=3k = 3, the number of factors that made them.

A target share would mislead here. Reaching 90% takes 9 components (90.39%), but components 4 to 9 carry only 1.43% to 2.14% each. They are the assets' own noise, which has no common direction; with noise this large, a gap is a better guide than a target.

What the components look like

  • v1\mathbf{v}_1 has all 20 entries positive, from 0.16 to 0.30: the whole market moving together.
  • v2\mathbf{v}_2 is positive for the first ten assets (0.12 to 0.30) and negative for the last ten (−0.31-0.31 to −0.08-0.08): the first ten against the rest.
  • v3\mathbf{v}_3 mixes signs, with no simple pattern: the third move.

The model's three loading vectors are known here, because we made the data. Each lies almost inside the span of v1\mathbf{v}_1, v2\mathbf{v}_2 and v3\mathbf{v}_3 (lesson 3.5.1): projecting it there (lesson 3.5.2) keeps 99.86%, 99.32% and 99.08% of its length.

But the vi\mathbf{v}_i are not the model's factors one by one. v1\mathbf{v}_1 lies 8.0° from the market's loading vector and v2\mathbf{v}_2 10.1° from the split, but v3\mathbf{v}_3 is 44.5° from the third. The SVD forces its directions to be perpendicular, and the model's loading vectors are not: it finds the space of the factors, not their names.

Try it

The shares of the first ten components, the ratios σi/σi+1\sigma_i/\sigma_{i+1}, and the number of components a 90% target needs.

Then the first three components to two decimals, with signs fixed. For each model loading vector, scaled to length 1, the last loop prints the length kept by projecting onto them, and its angle to v1\mathbf{v}_1, v2\mathbf{v}_2 or v3\mathbf{v}_3.

⌘/Ctrl + Enter runs the cell

A rank-3 copy of the returns

Keep three pieces (lesson 3.8.2): X3=U3S3V3⊤X_3 = U_3S_3V_3^\top. Each month's 20 centred returns become a mix of the three patterns v1\mathbf{v}_1, v2\mathbf{v}_2 and v3\mathbf{v}_3, with three amounts per month.

Storage. XcX_c holds 2,400 numbers. X3X_3 needs 3×(120+20+1)=4233 \times (120 + 20 + 1) = 423, or 17.6%: three histories of 120 months, three patterns of 20 assets, and three singular values.

The residual. What is left, E=Xc−X3E = X_c - X_3, carries 20.51% of the squared size: 100% minus the 79.49% kept. It should look like the assets' own noise, and it does:

returnsresiduals
mean correlation of a pair0.54−0.05-0.05
typical size, mean of ∣ρ∣\lvert\rho\rvert0.540.11
lowest and highest0.2 to 0.81−0.48-0.48 to 0.27

The common moves are gone: the residual correlations sit around 0, as in the figure. They lean slightly negative because EE is perpendicular to the three directions taken out, which pushes what is left a little apart.

Each asset's residual volatility tracks the noise the model gave it, from 2% to 3.5% a month: the two lists have a correlation of 0.89. The copy has split each asset's risk into a common part and its own part.

Try it

X3 is the rank-3 copy; E the residual. The first line gives the storage, the second the residual's share of the squared size.

np.triu_indices(m, 1) picks the 190 entries above the diagonal of a 20×2020 \times 20 matrix: one per pair of assets. The last line compares the residual volatilities with the model's.

⌘/Ctrl + Enter runs the cell

A compressed covariance matrix

The covariance matrix of the rank-3 copy keeps three terms of the spectral decomposition: C3=V3Λ3V3⊤C_3 = V_3\Lambda_3V_3^\top, with Λ3\Lambda_3 the diagonal of λ1\lambda_1, λ2\lambda_2 and λ3\lambda_3. For a spread-out portfolio it is almost the whole story. With 120\frac{1}{20} in each asset, CC gives a variance of 0.00182 a month, a volatility of 14.78% a year, and C3C_3 gives 0.001819.

The trap: a rank-3 matrix

C3C_3 has rank 3, so it is singular (lesson 3.4.1): there are portfolios it calls riskless. Take a pair trade, long asset 1 and short asset 2, and adjust it slightly so that it has no exposure to v1\mathbf{v}_1, v2\mathbf{v}_2 or v3\mathbf{v}_3. Its holdings are (0.988,−0.983,…)(0.988, -0.983, \ldots), with the other 18 between −0.07-0.07 and 0.05.

C3C_3 gives this portfolio a variance of 0, up to rounding. CC gives it 0.001212, a volatility of 3.48% a month. An optimiser handed C3C_3 would load up on such trades, believing them free of risk.

The fix: put back each asset's own risk

Add back, on the diagonal, the variance that C3C_3 misses for each asset: D=diag⁡(C)−diag⁡(C3)D = \operatorname{diag}(C) - \operatorname{diag}(C_3). These are the residual variances.

Rule: A factor-model covariance matrix

Cf=VkΛkVk⊤+DDii=Cii−(VkΛkVk⊤)ii\begin{gathered} C_f = V_k\Lambda_kV_k^\top + D \\[4pt] D_{ii} = C_{ii} - (V_k\Lambda_kV_k^\top)_{ii} \end{gathered}

VkV_k holds the first kk principal components and Λk\Lambda_k their variances. DD is diagonal, holding what the components miss of each asset's variance, so CfC_f has the same diagonal as CC. If every Dii>0D_{ii} > 0, every portfolio other than zero gets a variance above 0: CfC_f is positive definite (lesson 3.7.1).

CfC_f gives the pair trade 3.39% a month, close to the 3.48% from CC, and the spread-out portfolio 14.91% a year. Its smallest eigenvalue is 0.000416, above 0, so no portfolio is called riskless.

It needs 20×3+3+20=8320 \times 3 + 3 + 20 = 83 numbers, against 210 for CC. With 500 assets the gap is wider: 2,003 against 125,250.

Closer to the truth. We made the data, so the model's own covariance matrix is known. The Frobenius distance (lesson 3.8.2) from CC to it is 0.0074; from CfC_f it is 0.00685. Replacing the 17 small components by a diagonal threw away mostly noise, so the compressed matrix is the better estimate.

Try it

C3 keeps three components. The first lines check that it is the covariance of X3X_3 and has rank 3, then price the spread-out portfolio with CC and C3C_3.

pair is long asset 1, short asset 2, minus its projection onto the three components. The loop prices it with CC, C3C_3 and CfC_f; for C3C_3 it prints whether the variance is below 10−1210^{-12}, since the digits there are rounding noise.

⌘/Ctrl + Enter runs the cell
Check yourself
A colleague stores only C3=V3Λ3V3⊤C_3 = V_3\Lambda_3V_3^\top for 20 assets and runs a minimum-variance optimiser on it. What goes wrong?

On the desk

Try it

The cell follows the notes in order: the SVD of gross returns, v1\mathbf{v}_1 as a portfolio, the two halves of the sample, 15 months, and the shares after scaling each column.

Angles between directions come from the dot product of unit vectors (lesson 3.1.2); abs ignores their signs.

⌘/Ctrl + Enter runs the cell

Case study 3.6.5 found the principal components of yield changes from their covariance matrix with eigh. Run the steps of this case study on those changes, centred, and the right singular vectors are the level, slope and curvature it found, with no covariance matrix formed. Working on XcX_c itself is also the more accurate route, for the reason lesson 3.5.3 gave for lstsq.

Exercise

A factor-model covariance matrix

Write two functions, with NumPy, for a returns matrix with one row per period and one column per asset.

  1. variance_share(returns, k) returns the share of the total variance carried by the first kk principal components, ∑i≤kσi2/∑iσi2\sum_{i \le k}\sigma_i^2 / \sum_i \sigma_i^2, for the centred returns.
  2. factor_covariance(returns, k) returns Cf=VkΛkVk⊤+DC_f = V_k\Lambda_kV_k^\top + D, with λi=σi2/(n−1)\lambda_i = \sigma_i^2/(n - 1) and DD diagonal, so that its diagonal equals the diagonal of np.cov(returns, rowvar=False).

Both raise ValueError if returns is not 2-D, has fewer than 2 rows, or contains a missing value (nan); and unless k is a whole number from 1 to the number of assets.

The tests use this case study's returns. They check CfC_f against a version built with eigh, and try gross returns 1+r1 + r, which must give the same answer. A version that forgets to centre fails them.

See the tests
import numpy as np


def _returns():
    """The invented returns of case study 3.8.4: 120 months of 20 assets, seed 38."""
    rng = np.random.default_rng(38)
    n, m = 120, 20
    loadings = np.column_stack([
        rng.uniform(0.7, 1.3, m),
        np.r_[rng.uniform(0.4, 1.0, 10), -rng.uniform(0.4, 1.0, 10)],
        rng.uniform(-1.0, 1.0, m),
    ])
    factors = rng.standard_normal((n, 3)) * [0.045, 0.03, 0.03]
    specific = rng.uniform(0.02, 0.035, m)
    means = rng.uniform(0.004, 0.012, m)
    return means + factors @ loadings.T + rng.standard_normal((n, m)) * specific


X = _returns()
C = np.cov(X, rowvar=False)


def _reference(k):
    lam, Q = np.linalg.eigh(C)
    lam, Q = lam[::-1][:k], Q[:, ::-1][:, :k]
    common = Q @ np.diag(lam) @ Q.T
    return common + np.diag(np.diag(C) - np.diag(common))


def _raises(f, what):
    try:
        f()
    except ValueError:
        return
    raise AssertionError(f"expected ValueError for {what}")


def test_share_of_three():
    """Three components carry 79.49% of the variance of the case study's returns"""
    expect_close(variance_share(X, 3), 0.794933, what="variance_share(X, 3)", rel=1e-5)


def test_share_of_all():
    """All 20 components carry all of it"""
    expect_close(variance_share(X, 20), 1.0, what="variance_share(X, 20)", rel=1e-9)


def test_matches_eigh():
    """factor_covariance(X, 3) matches the same matrix built from eigh of np.cov"""
    expect_close(factor_covariance(X, 3), _reference(3), what="factor_covariance(X, 3)", rel=1e-7, abs_tol=1e-12)


def test_diagonal_of_cov():
    """Its diagonal is the diagonal of np.cov: each asset keeps its own variance"""
    expect_close(np.diag(factor_covariance(X, 3)), np.diag(C), what="diagonal of factor_covariance(X, 3)", rel=1e-9)


def test_positive_definite():
    """With 3 components it is symmetric and positive definite (smallest eigenvalue above 0)"""
    Cf = np.asarray(factor_covariance(X, 3))
    expect_equal(bool(np.allclose(Cf, Cf.T)), True, what="factor_covariance(X, 3) is symmetric")
    expect_equal(bool(np.linalg.eigvalsh(Cf).min() > 1e-5), True, what="smallest eigenvalue above 1e-5")


def test_all_components_give_cov():
    """With all 20 components it is np.cov itself"""
    expect_close(factor_covariance(X, 20), C, what="factor_covariance(X, 20)", rel=1e-7, abs_tol=1e-12)


def test_gross_returns():
    """Gross returns 1 + r give the same share and the same matrix: the returns are centred"""
    expect_close(variance_share(1 + X, 3), variance_share(X, 3), what="variance_share(1 + X, 3)", rel=1e-9)
    expect_close(factor_covariance(1 + X, 3), _reference(3), what="factor_covariance(1 + X, 3)", rel=1e-6, abs_tol=1e-12)


def test_bad_k():
    """k = 0, k = 21 for 20 assets and k = 2.5 raise ValueError"""
    for k in (0, 21, 2.5):
        _raises(lambda: factor_covariance(X, k), f"k = {k}")
        _raises(lambda: variance_share(X, k), f"variance_share with k = {k}")


def test_bad_returns():
    """A 1-D array, a single row and a missing value raise ValueError"""
    _raises(lambda: factor_covariance(X[:, 0], 1), "a 1-D array")
    _raises(lambda: factor_covariance(X[:1], 1), "a single row")
    gap = X.copy()
    gap[5, 3] = np.nan
    _raises(lambda: factor_covariance(gap, 3), "a nan in the returns")
    _raises(lambda: variance_share(gap, 3), "variance_share with a nan")

Summary card

  • Centre the returns matrix column by column: XcX_c. Its SVD Xc=USV⊤X_c = USV^\top gives the principal components vi\mathbf{v}_i (case study 3.6.5) and their variances λi=σi2/(n−1)\lambda_i = \sigma_i^2/(n - 1), without forming CC.
  • The share of variance of component ii is σi2/∑σj2\sigma_i^2 / \sum \sigma_j^2. A gap in the singular values suggests how many factors there are; here 3, carrying 79.49%. With noisy data a target share can ask for components that are only noise.
  • The components span the factors' space but are not the factors one by one; their signs are arbitrary and they move with the data window.
  • A rank-kk copy of the returns stores k(n+m+1)k(n + m + 1) numbers; its residual should look like noise, with correlations near 0.
  • Ck=VkΛkVk⊤C_k = V_k\Lambda_kV_k^\top alone is singular and calls some portfolios riskless. Add the residual variances on the diagonal: Cf=Ck+DC_f = C_k + D gives every portfolio a positive variance, keeps CC's diagonal, and needs mk+k+mmk + k + m numbers.

Free further reading

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