Skip to content

Commit e8d9498

Browse files
committed
Now the singular matrix error when calling quadprog is captured and no eigenvalue decomposition is necessary (the argument control_numerical_ill_conditioning in design() is not necessary).
Also, the convergence criteria has been fixed (for cases where the variables or objective value were almost zero already).
1 parent 324492f commit e8d9498

File tree

3 files changed

+65
-64
lines changed

3 files changed

+65
-64
lines changed

src/riskparityportfolio/rpp.py

+4-10
Original file line numberDiff line numberDiff line change
@@ -52,15 +52,12 @@ class RiskParityPortfolio:
5252
>>> n = 10
5353
>>> U = np.random.multivariate_normal(mean=np.zeros(n), cov=0.1 * np.eye(n), size=round(.7 * n)).T
5454
>>> Sigma = U @ U.T + np.eye(n)
55-
# Basic usage with equality constraints:
55+
# Basic usage with default constraints sum(w) = 1 and w >= 0:
5656
>>> my_portfolio = rpp.RiskParityPortfolio(Sigma)
57-
>>> my_portfolio.design(Cmat=Cmat, cvec=cvec)
57+
>>> my_portfolio.design()
5858
>>> my_portfolio.weights
5959
# Basic usage with equality and inequality constraints:
6060
>>> my_portfolio.design(Cmat=Cmat, cvec=cvec, Dmat=Dmat, dvec=dvec)
61-
# Further control of the iterative algorithm:
62-
>>> my_portfolio.design(Cmat=Cmat, cvec=cvec, Dmat=Dmat, dvec=dvec,
63-
>>> verbose=False, tau=1e-10, control_numerical_ill_conditioning=True)
6461
"""
6562

6663
def __init__(
@@ -197,18 +194,15 @@ def add_variance(self, lmd):
197194
self.lmd = lmd
198195
self.has_variance = True
199196

200-
def design(self, verbose=True, control_numerical_ill_conditioning=False, **kwargs):
197+
def design(self, verbose=True, **kwargs):
201198
"""Optimize the portfolio.
202199
203200
Parameters
204201
----------
205202
verbose : boolean
206203
Whether to print the optimization process.
207-
control_numerical_ill_conditioning : boolean
208-
Whether to control numerical ill-conditioning of matrices over the iterations
209-
(at the expense of a computational cost of O(n^3) per iteration for n assets).
210204
kwargs : dict
211205
Dictionary of parameters to be passed to SuccessiveConvexOptimizer().
212206
"""
213207
self.sca = SuccessiveConvexOptimizer(self, **kwargs)
214-
self.sca.solve(verbose=verbose, control_numerical_ill_conditioning=control_numerical_ill_conditioning)
208+
self.sca.solve(verbose)

src/riskparityportfolio/sca.py

+28-29
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ def get_objective_function_value(self):
230230
obj += self.portfolio.lmd * self.portfolio.volatility ** 2
231231
return obj
232232

233-
def iterate(self, verbose=True, control_numerical_ill_conditioning=False):
233+
def iterate(self, verbose=True):
234234
wk = self.portfolio.weights
235235
g = self.gvec(wk)
236236
A = np.ascontiguousarray(self.Amat(wk))
@@ -246,58 +246,57 @@ def iterate(self, verbose=True, control_numerical_ill_conditioning=False):
246246
np.hstack([np.zeros((self.number_of_dummy_vars, self.portfolio.number_of_assets)),
247247
self.tau * np.eye(self.portfolio.number_of_assets)])])
248248
q = np.concatenate([q, -self.tau * self.dummy_vars])
249-
if control_numerical_ill_conditioning:
250-
eigvals = np.linalg.eigvals(Q)
251-
if min(eigvals) < 0 or abs(max(eigvals)/min(eigvals)) > 1e8:
252-
reg = 0
249+
# Call QP solver (min 0.5*x.T G x + a.T x s.t. C.T x >= b) controlling for ill-conditioning:
250+
try:
251+
w_hat = quadprog.solve_qp(Q, -q, C=self.CCmat, b=self.bvec, meq=self.meq)[0]
252+
except ValueError as e:
253+
if str(e) == "matrix G is not positive definite":
254+
# Add a small positive value to the diagonal of Q to make it positive definite
253255
if verbose:
254-
print("\n")
255-
if min(eigvals) < 0:
256-
if verbose:
257-
print("--> Q is not positive definite: adding regularization term before calling QP solver.")
258-
reg += 2*abs(min(eigvals))
259-
if max(eigvals + reg)/min(eigvals + reg) > 1e8:
260-
if verbose:
261-
print("--> Q is ill-conditioned: adding regularization term before calling QP solver.")
262-
reg += max(eigvals)/1e8
263-
if verbose:
264-
print(" Before regularization: cond. number = {:,.0f}".format(max(eigvals) / min(eigvals)))
265-
print(" After regularization: cond. number = {:,.0f}".format(max(eigvals + reg) / min(eigvals + reg)))
266-
Q += reg * np.eye(Q.shape[0])
267-
# Solver for: min 0.5*x.T G x + a.T x s.t. C.T x >= b
268-
w_hat = quadprog.solve_qp(Q, -q, C=self.CCmat, b=self.bvec, meq=self.meq)[0]
256+
print(" Matrix Q is not positive definite: adding regularization term and then calling QP solver again.")
257+
#eigvals = np.linalg.eigvals(Q)
258+
#print(" - before regularization: cond. number = {:,.0f}".format(max(eigvals) / min(eigvals)))
259+
#print(" - after regularization: cond. number = {:,.0f}".format(max(eigvals + np.trace(Q)/1e7) / min(eigvals + np.trace(Q)/1e7)))
260+
Q += np.eye(Q.shape[0]) * np.trace(Q)/1e7
261+
w_hat = quadprog.solve_qp(Q, -q, C=self.CCmat, b=self.bvec, meq=self.meq)[0]
262+
else:
263+
# If the error is different, re-raise it
264+
raise
269265
self.portfolio.weights = wk + self.gamma * (w_hat[:self.portfolio.number_of_assets] - wk)
270266
fun_next = self.get_objective_function_value()
271267
self.objective_function.append(fun_next)
272268
has_w_converged = (
273-
np.abs(self.portfolio.weights - wk)
274-
<= 0.5 * self.wtol * (np.abs(self.portfolio.weights) + np.abs(wk))
269+
(np.abs(self.portfolio.weights - wk) <= self.wtol * 0.5 * (np.abs(self.portfolio.weights) + np.abs(wk)))
270+
| ((np.abs(self.portfolio.weights) < 1e-6) & (np.abs(wk) < 1e-6))
275271
).all()
276272
has_fun_converged = (
277-
np.abs(self._funk - fun_next)
278-
<= 0.5 * self.funtol * (np.abs(self._funk) + np.abs(fun_next))
279-
).all()
273+
(np.abs(self._funk - fun_next) <= self.funtol * 0.5 * (np.abs(self._funk) + np.abs(fun_next)))
274+
| ((np.abs(self._funk) <= 1e-10) & (np.abs(fun_next) <= 1e-10))
275+
)
280276
if self.number_of_dummy_vars > 0:
281277
have_dummies_converged = (
282-
np.abs(w_hat[self.portfolio.number_of_assets:] - self.dummy_vars)
283-
<= 0.5 * self.wtol * (np.abs(w_hat[self.portfolio.number_of_assets:]) + np.abs(self.dummy_vars))
278+
(np.abs(w_hat[self.portfolio.number_of_assets:] - self.dummy_vars) <= self.wtol * 0.5 *
279+
(np.abs(w_hat[self.portfolio.number_of_assets:]) + np.abs(self.dummy_vars)))
280+
| ((np.abs(w_hat[self.portfolio.number_of_assets:]) < 1e-6) & (np.abs(self.dummy_vars) < 1e-6))
284281
).all()
285282
self.dummy_vars = w_hat[self.portfolio.number_of_assets:]
286283
else:
287284
have_dummies_converged = True
288285
if (has_w_converged and have_dummies_converged) or has_fun_converged:
286+
# if verbose:
287+
# print(f" Has func. converged: {has_fun_converged}; has w converged: {has_w_converged}")
289288
return False
290289
self.gamma = self.gamma * (1 - self.zeta * self.gamma)
291290
self._funk = fun_next
292291
return True
293292

294-
def solve(self, verbose=True, control_numerical_ill_conditioning=False):
293+
def solve(self, verbose=True):
295294
i = 0
296295
iterator = range(self.maxiter)
297296
if verbose:
298297
iterator = tqdm(iterator)
299298
for _ in iterator:
300-
if not self.iterate(verbose=verbose, control_numerical_ill_conditioning=control_numerical_ill_conditioning):
299+
if not self.iterate(verbose=verbose):
301300
break
302301
i += 1
303302

src/riskparityportfolio/tests/test_modern_rpp.py

+33-25
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,11 @@ def test_random_covmat():
2828
Sigma = U @ U.T + np.eye(N)
2929
#pdb.set_trace()
3030
my_portfolio = rpp.RiskParityPortfolio(Sigma, budget=b)
31-
my_portfolio.design(verbose=False)
31+
my_portfolio.design()
3232
w = my_portfolio.weights
3333

3434
# assert that the portfolio respect the budget constraint
35-
np.testing.assert_almost_equal(np.sum(w), 1)
35+
np.testing.assert_almost_equal(np.sum(w), 1.0)
3636
# assert that the portfolio respect the no-shortselling constraint
3737
np.testing.assert_equal(all(w >= 0), True)
3838
# assert that the desired risk contributions are attained
@@ -49,23 +49,23 @@ def test_singularity_issues_with_G_matrix():
4949
Sigma = U @ U.T # singular covariance matrix
5050

5151
my_portfolio = rpp.RiskParityPortfolio(Sigma, budget=b)
52-
my_portfolio.design(verbose=False, tau=1e-10, control_numerical_ill_conditioning=True)
53-
w1 = my_portfolio.weights
54-
55-
my_portfolio = rpp.RiskParityPortfolio(Sigma, budget=b)
56-
my_portfolio.design(tau=1e-4)
57-
w2 = my_portfolio.weights
58-
np.testing.assert_allclose(w1, w2, rtol=1e-3)
52+
my_portfolio.design(verbose=False, tau=1e-10) # <-- force ill-conditioned matrix
53+
w_ref = my_portfolio.weights
5954

6055
my_portfolio = rpp.RiskParityPortfolio(Sigma, budget=b)
61-
my_portfolio.design(tau=0.05 * np.sum(np.diag(Sigma)) / (2 * N))
62-
w3 = my_portfolio.weights
63-
np.testing.assert_allclose(w1, w3, rtol=1e-3)
56+
my_portfolio.design(tau=1e-4) # <-- default tau, should be fine
57+
w = my_portfolio.weights
58+
np.testing.assert_allclose(w, w_ref, rtol=1e-3)
6459

65-
my_portfolio = rpp.RiskParityPortfolio(Sigma, budget=b)
66-
my_portfolio.design(tau=2 * 0.1 ** 2 / (2 * N))
67-
w4 = my_portfolio.weights
68-
np.testing.assert_allclose(w1, w4, rtol=1e-3)
60+
# my_portfolio = rpp.RiskParityPortfolio(Sigma, budget=b)
61+
# my_portfolio.design(verbose=True, tau=0.05 * np.sum(np.diag(Sigma)) / (2 * N))
62+
# w = my_portfolio.weights
63+
# np.testing.assert_allclose(w, w_ref, rtol=1e-3)
64+
#
65+
# my_portfolio = rpp.RiskParityPortfolio(Sigma, budget=b)
66+
# my_portfolio.design(verbose=True, tau=2 * 0.1 ** 2 / (2 * N))
67+
# w = my_portfolio.weights
68+
# np.testing.assert_allclose(w, w_ref, rtol=1e-3)
6969

7070

7171
def test_constraints():
@@ -95,7 +95,7 @@ def test_constraints():
9595
np.testing.assert_allclose(w1, w3, rtol=1e-5)
9696

9797

98-
# Upper bound w <= 0.03
98+
# Additional upper bound: w <= 0.03
9999
my_portfolio = rpp.RiskParityPortfolio(Sigma)
100100
my_portfolio.design(Cmat=np.ones((1, N)), cvec=np.array([1.0]),
101101
Dmat=np.vstack([np.eye(N), -np.eye(N)]), dvec=np.concatenate([0.03*np.ones(N), np.zeros(N)]))
@@ -105,16 +105,24 @@ def test_constraints():
105105
print(max(w))
106106
np.testing.assert_array_less(w, (0.03 + 1e-3)*np.ones(N))
107107

108-
# Bounds for sum: 0.5 <= sum(w) <= 1 (
108+
109+
# Bounds for sum(w): 0.5 <= sum(w) <= 1 (tending to upper bound)
109110
my_portfolio = rpp.RiskParityPortfolio(Sigma)
111+
my_portfolio.add_mean_return(alpha=1e-6, mean=np.ones(N)) # this adds sum(w) to also maximize sum(w)
110112
my_portfolio.design(Cmat=np.empty((0, N)), cvec=[],
111113
Dmat=np.vstack([-np.ones((1,N)), np.ones((1,N))]), dvec=np.array([-0.5, 1]))
112114
w = my_portfolio.weights
113-
np.testing.assert_equal(sum(w) <= 1.0 + 1e-5, True)
114-
np.testing.assert_equal(sum(w) >= 0.5 - 1e-5, True)
115+
np.testing.assert_almost_equal(np.sum(w), 1.0)
116+
117+
118+
# Bounds for sum(w): 0.5 <= sum(w) <= 1 (tending to lower bound)
119+
my_portfolio = rpp.RiskParityPortfolio(Sigma)
120+
my_portfolio.add_mean_return(alpha=1e-6, mean=-np.ones(N)) # this adds -sum(w) to also minimize sum(w)
121+
my_portfolio.design(Cmat=np.empty((0, N)), cvec=[],
122+
Dmat=np.vstack([-np.ones((1,N)), np.ones((1,N))]), dvec=np.array([-0.5, 1]))
123+
w = my_portfolio.weights
124+
np.testing.assert_almost_equal(np.sum(w), 0.5, decimal=3)
115125

116-
#np.testing.assert_array_less(sum(w), 1.0)
117-
#np.testing.assert_array_less(-sum(w), -0.5)
118126

119127

120128
def test_dummy_variables():
@@ -127,7 +135,7 @@ def test_dummy_variables():
127135
my_portfolio = rpp.RiskParityPortfolio(Sigma)
128136
my_portfolio.design(Cmat=np.ones((1, N)), cvec=np.array([1.0]),
129137
Dmat=np.vstack([np.eye(N), -np.eye(N)]), dvec=np.concatenate([0.03*np.ones(N), np.zeros(N)]))
130-
w1 = my_portfolio.weights
138+
w_ref = my_portfolio.weights
131139

132140
# Equivalently: sum(w) = 1, 0 <= w <= u, and u <= 0.03 (new dummy variable u, with w_tilde = [w; u])
133141
my_portfolio = rpp.RiskParityPortfolio(Sigma)
@@ -136,6 +144,6 @@ def test_dummy_variables():
136144
np.hstack([np.eye(N), -np.eye(N)]),
137145
np.hstack([np.zeros((N, N)), np.eye(N)])]),
138146
dvec=np.concatenate([np.zeros(N), np.zeros(N), 0.03*np.ones(N)]))
139-
w2 = my_portfolio.weights
147+
w = my_portfolio.weights
140148

141-
np.testing.assert_allclose(w1, w2, rtol=1e-5, atol=1e-5)
149+
np.testing.assert_allclose(w, w_ref, rtol=1e-5, atol=1e-5)

0 commit comments

Comments
 (0)