Skip to content

Commit 1e5a4ce

Browse files
[BUG] raise an informative error when CLA gets a singular covariance matrix
Closes #737 The critical line algorithm inverts the covariance submatrix of the free assets on each iteration. When the covariance matrix is singular — e.g. perfectly correlated or duplicated assets, or fewer observations than assets — that inversion can fail, and users saw a bare `numpy.linalg.LinAlgError: Singular matrix` raised from a private method, with no indication of what was wrong or how to fix it. Route the four inversion sites through a helper that translates the LinAlgError into a ValueError explaining the likely cause and pointing at concrete remedies. Note that validating positive-definiteness up front (as the issue suggests) would be a regression: a singular covariance matrix only breaks CLA if a *submatrix* of the free assets happens to be singular. In a randomised check over 40 rank-deficient covariance matrices, 29 solved fine and only 4 raised LinAlgError, so an up-front check would reject inputs that work today. Translating the error where it actually occurs keeps those working and only improves the failure path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a6638d2 commit 1e5a4ce

2 files changed

Lines changed: 80 additions & 5 deletions

File tree

‎pypfopt/cla.py‎

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,45 @@ def _compute_lambda(self, covarF_inv, covarFB, meanF, wB, i, bi):
192192
res = float(res[0, 0])
193193
return res, bi
194194

195+
@staticmethod
196+
def _invert_covar_free(covarF):
197+
"""
198+
Invert the covariance submatrix of the currently free assets.
199+
200+
The critical line algorithm requires this submatrix to be invertible. A
201+
singular covariance matrix – e.g. one containing perfectly correlated or
202+
duplicated assets, or estimated from fewer observations than assets – can
203+
make it singular, in which case numpy raises a bare ``LinAlgError`` from
204+
deep inside the algorithm. Translate it into an actionable error instead.
205+
206+
Parameters
207+
----------
208+
covarF : np.ndarray
209+
covariance submatrix of the free assets
210+
211+
Raises
212+
------
213+
ValueError
214+
if the submatrix is singular
215+
216+
Returns
217+
-------
218+
np.ndarray
219+
inverse of ``covarF``
220+
"""
221+
try:
222+
return np.linalg.inv(covarF)
223+
except np.linalg.LinAlgError:
224+
raise ValueError(
225+
"The covariance matrix is singular, so the critical line algorithm "
226+
"cannot invert it. This usually means some assets are perfectly "
227+
"correlated or duplicated, or that the covariance matrix was "
228+
"estimated from fewer observations than there are assets. Consider "
229+
"removing redundant assets, or using a shrinkage estimator such as "
230+
"risk_models.CovarianceShrinkage, which returns a positive definite "
231+
"matrix."
232+
) from None
233+
195234
def _get_matrices(self, f):
196235
# Slice covarF,covarFB,covarB,meanF,meanB,wF,wB
197236
covarF = self._reduce_matrix(self.cov_matrix, f, f)
@@ -339,7 +378,7 @@ def _solve(self):
339378
l_in = None
340379
if len(f) > 1:
341380
covarF, covarFB, meanF, wB = self._get_matrices(f)
342-
covarF_inv = np.linalg.inv(covarF)
381+
covarF_inv = self._invert_covar_free(covarF)
343382
j = 0
344383
for i in f:
345384
lam, bi = self._compute_lambda(
@@ -354,7 +393,7 @@ def _solve(self):
354393
b = self._get_b(f)
355394
for i in b:
356395
covarF, covarFB, meanF, wB = self._get_matrices(f + [i])
357-
covarF_inv = np.linalg.inv(covarF)
396+
covarF_inv = self._invert_covar_free(covarF)
358397
lam, bi = self._compute_lambda(
359398
covarF_inv,
360399
covarFB,
@@ -371,7 +410,7 @@ def _solve(self):
371410
# 3) compute minimum variance solution
372411
self.ls.append(0)
373412
covarF, covarFB, meanF, wB = self._get_matrices(f)
374-
covarF_inv = np.linalg.inv(covarF)
413+
covarF_inv = self._invert_covar_free(covarF)
375414
meanF = np.zeros(meanF.shape)
376415
else:
377416
# 4) decide lambda
@@ -383,7 +422,7 @@ def _solve(self):
383422
self.ls.append(l_out)
384423
f.append(i_out)
385424
covarF, covarFB, meanF, wB = self._get_matrices(f)
386-
covarF_inv = np.linalg.inv(covarF)
425+
covarF_inv = self._invert_covar_free(covarF)
387426
# 5) compute solution vector
388427
wF, g = self._compute_w(covarF_inv, covarFB, meanF, wB)
389428
for i in range(len(f)):

‎tests/test_cla.py‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import numpy as np
22
import pytest
33

4-
from pypfopt import risk_models
4+
from pypfopt import expected_returns, risk_models
55
from pypfopt.cla import CLA
66
from tests.utilities_for_tests import get_data, setup_cla
77

@@ -96,6 +96,42 @@ def test_cla_two_assets():
9696
assert CLA(mu, cov)
9797

9898

99+
def test_cla_singular_covariance_raises():
100+
# Perfectly correlated assets give a positive semidefinite but singular
101+
# covariance matrix, which CLA cannot invert. The user should get an
102+
# actionable error rather than a bare numpy LinAlgError.
103+
mu = np.array([0.1, 0.2])
104+
cov = np.array([[0.01, 0.01], [0.01, 0.01]])
105+
106+
for method in ("max_sharpe", "min_volatility", "efficient_frontier"):
107+
with pytest.raises(ValueError, match="cannot invert") as e:
108+
getattr(CLA(mu, cov), method)()
109+
# np.linalg.LinAlgError subclasses ValueError, so assert explicitly that
110+
# the raw numpy error is not what surfaces to the user.
111+
assert not isinstance(e.value, np.linalg.LinAlgError)
112+
113+
114+
def test_cla_singular_covariance_fixed_by_shrinkage():
115+
# The remedy suggested by the error message should actually work: shrinkage
116+
# returns a positive definite matrix, which CLA can handle.
117+
df = get_data()
118+
# duplicate a column to make the sample covariance singular
119+
df = df.iloc[:, :3].copy()
120+
df[df.columns[1]] = df[df.columns[0]]
121+
mu = expected_returns.mean_historical_return(df)
122+
123+
sample_cov = risk_models.sample_cov(df)
124+
assert np.min(np.linalg.eigvalsh(sample_cov)) < 1e-12
125+
with pytest.raises(ValueError, match="cannot invert"):
126+
CLA(mu, sample_cov).min_volatility()
127+
128+
shrunk_cov = risk_models.CovarianceShrinkage(df).ledoit_wolf()
129+
assert np.min(np.linalg.eigvalsh(shrunk_cov)) > 0
130+
w = CLA(mu, shrunk_cov).min_volatility()
131+
assert len(w) == 3
132+
np.testing.assert_almost_equal(sum(w.values()), 1)
133+
134+
99135
def test_cla_max_sharpe_semicovariance():
100136
df = get_data()
101137
cla = setup_cla()

0 commit comments

Comments
 (0)