What a correlation matrix must be
Case study 3.2.5 built the covariance matrix of three assets as . Here is the diagonal matrix of the volatilities 0.12, 0.18 and 0.25, and is the correlation matrix. The data are invented for illustration.
A variance is never negative, so must be positive semidefinite (lesson 3.7.1). And . With positive volatilities, every vector is for some , so must be positive semidefinite too.
Rule: A valid correlation matrix
Symmetric, with 1 on the diagonal, and positive semidefinite. Each entry then lies between and 1. The rows and columns of any two assets form a correlation matrix, which must be semidefinite too: . The first two conditions are checked entry by entry; the third needs a test from lesson 3.7.1 or 3.7.2.
The three assets pass, with , and . The eigenvalues of are 0.4571, 0.9124 and 1.6305 (lesson 3.7.1), its leading minors 1, 0.91 and 0.68, and Cholesky succeeds (lesson 3.7.2). So is positive definite: no portfolio of A, B and C is free of risk.
Try it
The first line checks symmetry, the diagonal and the largest entry in size. Then the three tests: eigenvalues, leading minors and Cholesky.
Three correlations that cannot hold together
Now the hook's correlations:
It is symmetric with 1 on its diagonal, and every entry is between and 1. It fails the test that matters:
- Leading minors: 1, 0.19 and . Case study 3.4.3 noted that lies between 0 and 1; this one is negative.
- Eigenvalues: , 1.2 and 2.1767. One is negative, so is indefinite.
- Cholesky:
np.linalg.choleskyraisesLinAlgError: Matrix is not positive definite.
The eigenvector of is about : long B against A and C. The round vector points nearly the opposite way, which gives the same sign: . With the volatilities, the weights give , the hook's variance.
Try it
The first lines show that the matrix passes the entry-by-entry checks and fails the leading minors.
eigh gives the eigenvalues and eigenvectors. The sign of an eigenvector is arbitrary, so the cell makes its middle entry positive. Then the form at , as a correlation and as a variance, and Cholesky's refusal.
Why: correlations are angles
Case study 3.1.4 found that a correlation is the cosine of the angle between two centred return vectors. A correlation of 0.9 is an angle of .
So A is within of B, and B within of C. Then A and C are at most apart, and their correlation is at least . A correlation of would need an angle of .
Keep and move :
| 0 | 0.5 | 0.62 | 0.8 | 1 | ||
|---|---|---|---|---|---|---|
| 0 | 0.036 | 0 | ||||
| smallest eigenvalue | 0 | 0.0658 | 0 |
The matrix is valid exactly when . At either end it is singular: some combination of the three has zero variance.
Case study 3.2.5's desk note met a second impossible set, with . Its eigenvalues are , 1.9 and 1.9.
Try it
The angle bound first: in degrees, doubled, and the cosine of that. Then the table, and case study 3.2.5's matrix.
Adding 0.0 turns a -0.0 into 0.0, where rounding leaves a tiny negative number.
Where a desk meets this
A correlation matrix computed by np.corrcoef from one table of returns, with the same dates for every asset, is always valid. It is a matrix of the form (lesson 3.7.1), scaled. Trouble starts when the entries come from different places:
- pairwise estimates, each pair on the dates both assets traded, so every entry uses different data;
- hand-set values for a stress scenario, as in the hook;
- blocks from different sources stitched into one matrix, such as equities from one model and rates from another.
A simple repair
A desk cannot simulate or optimise with an impossible matrix, so it repairs it. The simplest repair uses the spectral theorem of lesson 3.6.3, :
- 1
Find the eigenvalues and eigenvectors: .
- 2
Replace each negative eigenvalue by 0, giving , and rebuild .
- 3
Divide each entry by , to put 1 back on the diagonal.
Step 2 changes the diagonal, here to 1.1016, 1.1736 and 1.1016; step 3 puts the 1s back, as case study 3.4.3 turned into . The result:
Its eigenvalues are 0, 1.0894 and 1.9106: valid. The portfolio now has variance 0.014058, a volatility of 11.86%.
The effect. The repair moved every correlation, not only the doubtful one. The two estimates of 0.9 fell to 0.6748, and rose to .
The repaired matrix is singular. Its smallest eigenvalue is 0, so one combination of the assets has zero variance. In money, that combination is the weights , which add up to 1. A portfolio optimiser looking for low risk would pile into this "riskless" portfolio.
On a computer, that 0 comes out as a tiny number of either sign, so np.linalg.cholesky may accept the matrix on one computer and reject it on another.
Use a floor instead of 0. Raise each eigenvalue below 0.001 to 0.001, then rescale. The correlations become 0.6742 and , and the eigenvalues 0.0009, 1.0891 and 1.91. Cholesky now works, with last diagonal entry 0.058, and the "riskless" portfolio has a volatility of 1.2%: small, but no longer 0.
If only is doubtful, a better repair moves it alone, into its valid range from 0.62 to 1. Iterative methods find the closest valid matrix; this repair is simple, not the closest.
Try it
repair follows the steps; floor is the smallest eigenvalue it allows. The cell prints 's diagonal, the repair with floor 0 and with floor 0.001, and the floored matrix's Cholesky factor.
Then the variance of under each matrix. Last come the "riskless" weights: the zero eigenvalue's eigenvector, divided by the volatilities and scaled to add up to 1. Their variance is 0 up to rounding, so the cell prints a comparison.
Simulating correlated returns
With a valid matrix, a desk can simulate: draw many possible years of returns for A, B and C, and see what a portfolio does across them.
Independent draws
rng.standard_normal returns numbers whose histogram follows the bell curve of lesson 2.6.3. About 68.3% of them land between and 1. Their mean is close to 0, and their standard deviation, the square root of their variance, close to 1.
Draw a table of them with the seed 37, one row per draw. Each number is drawn separately, so the columns are independent: one tells you nothing about the others (module 5.5 makes this precise).
In this table the column means are , and 0.000, the standard deviations 1.001, 0.993 and 1.017, and 67.93% of the numbers lie between and 1. The sample correlations between columns are , 0.016 and 0.006. So the sample covariance matrix of is close to the identity .
Mixing them with L
Take , the Cholesky factor of (lesson 3.7.2), and set for each draw :
Read it row by row. A's return is . B's is : part of A's draw, plus a draw of its own.
C's return uses all three draws. is times the Cholesky factor of : each row of 's factor scaled by that asset's volatility.
Why the covariance comes out right. In the table, row of is , so . Centring each column, as case study 3.2.5 did, keeps this: .
Then by the rule of lesson 3.2.3. Dividing by gives, exactly, for any sample:
Since , . Lesson 5.5.3 gives the same rule for random variables.
Rule: Correlated draws
holds independent standard normal draws. Then has mean and covariance matrix . In code, with one draw per row, the table is mu + Z @ L.T.
From the 10,000 draws, the sample correlations for AB, AC and BC are about 0.30, 0.11 and 0.50, against 0.3, 0.1 and 0.5. The sample volatilities are 12.02%, 17.87% and 25.38%, and the portfolio has a sample volatility of 11.99%, against the model's 11.94%.
Independent draws and the returns made from them. Because is lower triangular, A and B use only and : each return dot is the image of a draw dot, and the circle of radius 2 lands on the dashed ellipse.
Try it
np.linalg.cholesky(Sigma) is , and it equals times 's factor. Then the checks on : means, standard deviations, the share between and 1, and the correlations.
X = Z @ L.T makes every row . The next line checks exactly; the last lines compare the sample with the model.
How case study 3.2.5 made its sample
Case study 3.2.5 drew 120 monthly returns with rng.multivariate_normal(mu / 12, Sigma / 12, size=120, method="cholesky") and the seed 7. That is this method: monthly means , and the Cholesky factor of , which is .
Rebuilt by hand, with drawn as a table, the portfolio's first three monthly returns are 0.0095, and 0.0311: case study 3.2.5's numbers. Without method="cholesky", NumPy takes another square root of , from the singular value decomposition (module 3.8): same covariance, different numbers.
Try it
The monthly factor is the annual one divided by . Then case study 3.2.5's sample by hand: with the monthly .
The last line compares it with multivariate_normal, with and without method="cholesky".
Try it
For each sample size, 400 simulations each give one estimate of ; the line prints their 5th and 95th percentiles and their standard deviation.
Then the order C, B, A: np.ix_(order, order) reorders the rows and columns of , and X[:, [2, 1, 0]] puts the columns back to A, B, C before the correlations are computed.
Repair, then simulate
Write two functions, with NumPy.
repair_correlation(C, floor=1e-3)returns a valid correlation matrix close toC, by this case study's repair. Raise every eigenvalue belowfloortofloor, rebuild, then divide entry by . RaiseValueErrorunlessCis square and symmetric with 1 on its diagonal.simulate_returns(mu, vols, corr, n, seed)returns an array of returns, one row per draw and one column per asset. Build and its Cholesky factor . DrawZ = np.random.default_rng(seed).standard_normal((n, k))in one call, and returnmu + Z @ L.T.
In simulate_returns, raise ValueError if a volatility is negative, the sizes do not match, or Cholesky fails. For the last, catch the LinAlgError and raise ValueError with a message saying to repair corr first.
The tests use the three assets and the hook's matrix. One rebuilds case study 3.2.5's sample from the seed 7, so draw exactly as above.
See the tests
import numpy as np
MU = np.array([0.06, 0.08, 0.11])
VOLS = np.array([0.12, 0.18, 0.25])
R = np.array([[1.0, 0.3, 0.1], [0.3, 1.0, 0.5], [0.1, 0.5, 1.0]])
BAD = np.array([[1.0, 0.9, -0.2], [0.9, 1.0, 0.9], [-0.2, 0.9, 1.0]])
def _expect_value_error(f, why):
try:
f()
except ValueError:
return
except np.linalg.LinAlgError:
raise AssertionError(f"expected ValueError, got LinAlgError: {why}")
raise AssertionError(f"expected ValueError: {why}")
def test_valid_matrix_unchanged():
"""The three assets' R, already valid, comes back unchanged"""
expect_close(repair_correlation(R), R, what="repair_correlation(R)", rel=1e-9, abs_tol=1e-9)
def test_repair_values():
"""The impossible matrix becomes 0.6742 for AB and BC and −0.0891 for AC (floor 0.001)"""
F = np.asarray(repair_correlation(BAD), dtype=float)
expect_close([F[0, 1], F[1, 2], F[0, 2]], [0.674246, 0.674246, -0.089102], what="the repaired correlations AB, BC, AC", rel=1e-4)
def test_repair_is_valid():
"""The repaired matrix is symmetric, has 1s on its diagonal and passes Cholesky"""
F = np.asarray(repair_correlation(BAD), dtype=float)
expect_close(F, F.T, what="the repaired matrix against its transpose")
expect_close(np.diag(F), np.ones(3), what="the diagonal of the repaired matrix")
assert np.linalg.eigvalsh(F).min() > 5e-4, f"the smallest eigenvalue should be about 0.0009, got {np.linalg.eigvalsh(F).min()}"
np.linalg.cholesky(F)
def test_repair_floor_zero():
"""With floor=0 the negative eigenvalue is clipped to 0: 0.6748 for AB"""
F = np.asarray(repair_correlation(BAD, floor=0.0), dtype=float)
expect_close(F[0, 1], 0.674764, what="the repaired AB correlation with floor=0", rel=1e-4)
expect_close(np.linalg.eigvalsh(F).min(), 0.0, what="the smallest eigenvalue with floor=0", abs_tol=1e-9)
def test_repair_rejects_bad_input():
"""A matrix that is not symmetric, or has 2 on its diagonal, raises ValueError"""
_expect_value_error(lambda: repair_correlation([[1, 0.5], [0.4, 1]]), "the matrix is not symmetric")
_expect_value_error(lambda: repair_correlation([[2, 0.5], [0.5, 1]]), "a diagonal entry is 2, not 1")
def test_simulate_shape_and_seed():
"""simulate_returns gives one row per draw and one column per asset, and repeats with the same seed"""
X = np.asarray(simulate_returns(MU, VOLS, R, 500, 1), dtype=float)
expect_equal(X.shape, (500, 3), what="the shape of simulate_returns(..., n=500)")
expect_close(np.asarray(simulate_returns(MU, VOLS, R, 500, 1)), X, what="a second run with the same seed")
def test_simulate_matches_case_study():
"""Case study 3.2.5's sample: seed 7, 120 months, gives portfolio returns 0.0095, −0.0369, 0.0311"""
X = np.asarray(simulate_returns(MU / 12, VOLS / np.sqrt(12), R, 120, 7), dtype=float)
r_p = X @ np.array([0.5, 0.3, 0.2])
expect_close(np.round(r_p[:3], 4), [0.0095, -0.0369, 0.0311], what="the first three monthly portfolio returns")
def test_simulate_statistics():
"""20,000 draws reproduce the means, volatilities and correlations closely"""
X = np.asarray(simulate_returns(MU, VOLS, R, 20_000, 11), dtype=float)
expect_close(X.mean(axis=0), MU, what="the sample means", abs_tol=0.01, rel=0)
expect_close(X.std(axis=0, ddof=1), VOLS, what="the sample volatilities", rel=0.03)
C = np.corrcoef(X, rowvar=False)
expect_close([C[0, 1], C[0, 2], C[1, 2]], [0.3, 0.1, 0.5], what="the sample correlations AB, AC, BC", abs_tol=0.03, rel=0)
def test_simulate_rejects_impossible_matrix():
"""The impossible correlations raise ValueError, and their repair simulates"""
_expect_value_error(lambda: simulate_returns(MU, VOLS, BAD, 100, 1), "the correlation matrix has a negative eigenvalue")
X = np.asarray(simulate_returns(MU, VOLS, repair_correlation(BAD), 100, 1), dtype=float)
expect_equal(X.shape, (100, 3), what="the shape after repair")
def test_simulate_rejects_bad_input():
"""A negative volatility, or sizes that do not match, raise ValueError"""
_expect_value_error(lambda: simulate_returns(MU, [0.12, -0.18, 0.25], R, 100, 1), "a volatility is negative")
_expect_value_error(lambda: simulate_returns(MU, VOLS[:2], R, 100, 1), "two volatilities for three assets")
Summary card
- A correlation matrix must be symmetric, with 1 on its diagonal, and positive semidefinite, because is a variance. Entries between and 1 are not enough.
- Test it with Cholesky, the eigenvalues or the leading minors. with has eigenvalue ; correlations as angles force .
- A simple repair clips the negative eigenvalues and rescales to a unit diagonal. It moves every correlation, and clipping to exactly 0 leaves a singular matrix with a "riskless" portfolio, so use a small positive floor.
- To simulate, draw independent standard normals and set with ; in code,
mu + Z @ L.T. The sample covariance is exactly . - Sample error shrinks like ; the order of the assets changes but not the covariance; fix the seed to repeat a run.