05 — Constrained Optimization and Failure Modes¶
Learning objectives¶
Set up a constrained mean-variance QP yourself in cvxpy; observe how
sensitive optimal weights are to small changes in expected-return
assumptions — the core motivation for shrinkage and Black-Litterman
later in the curriculum; add a max_weight constraint and see the
optimizer redistribute the excess.
Free learning pack¶
- MIT OCW Portfolio Theory https://ocw.mit.edu/courses/15-401-finance-theory-i-fall-2008/pages/video-lectures-and-slides/portfolio-theory/
- CVXPY quadratic program https://www.cvxpy.org/examples/basic/quadratic_program.html
reference/concepts/mean_variance_optimization.md
Do not search for more material until these are insufficient.
PREDICT¶
You bump one asset's expected return by just 20bp (0.002) — a tiny, plausibly-just-noise change. Will the optimizer's weights move by roughly 20bp too, or could they move by several percentage points?
Write the optimization on paper first¶
maximize mu^T w - (lambda / 2) * w^T Sigma w
subject to:
- sum weights = 1
- long only
- max weight
(The 1/2 matters — see reference/concepts/mean_variance_optimization.md
for why: it's what makes pm.robust.market_implied_returns invert this
exact objective correctly.)
import cvxpy as cp
import numpy as np
mu = np.array([.06, .08, .04])
cov = np.array([[.04, .01, .002], [.01, .09, .004], [.002, .004, .01]])
risk_aversion = 5.0
# MANUAL FIRST:
# create cp.Variable, objective (with the 1/2!), and constraints yourself,
# then .solve() and read out w.value.
weights = None
print(weights)
# CHECK (uncomment after your attempt - compare against the tested version):
# from pm.optimization import mean_variance
# assert np.allclose(weights, mean_variance(mu, cov, risk_aversion, long_only=True), atol=1e-3)
Failure-mode experiment: estimation-error sensitivity¶
Bump mu[0] by delta across a small range and re-solve each time.
Confirm (or refute) your PREDICT answer.
import matplotlib.pyplot as plt
from pm.optimization import mean_variance
# MANUAL FIRST:
# for delta in np.linspace(-0.01, 0.01, 21), build mu2 = mu.copy() with
# mu2[0] += delta, solve mean_variance(mu2, cov, risk_aversion), and
# collect asset 0's weight into `weight0_by_delta`.
deltas = np.linspace(-0.01, 0.01, 21)
weight0_by_delta = None
# CHECK (uncomment after your attempt):
# assert weight0_by_delta[0] < weight0_by_delta[-1], "weight should rise as mu[0] rises"
# swing = weight0_by_delta[-1] - weight0_by_delta[0]
# assert swing > 0.02, (
# f"a 2%-point total swing in mu[0] (only {2*0.01:.0%}) should move weight0 by "
# f"more than a couple points - got {swing:.3f}. That gap between a small input "
# "change and a large output change IS the instability this exercise demonstrates."
# )
fig, ax = plt.subplots(figsize=(7, 5))
ax.plot(deltas, weight0_by_delta, color="#1f6feb", marker="o", markersize=3)
ax.set_xlabel("change in asset 0's expected return (mu[0] + delta)")
ax.set_ylabel("optimal weight in asset 0")
ax.set_title("Mean-variance weight sensitivity to a small return assumption change")
plt.show()
Add a max_weight constraint¶
minimum_variance and mean_variance both accept max_weight — a
uniform per-asset cap. Re-solve with max_weight=0.5 and see where the
excess weight goes.
# MANUAL FIRST:
capped_weights = None
print(capped_weights)
# CHECK (uncomment after your attempt):
# assert (capped_weights <= 0.5 + 1e-6).all()
# assert np.isclose(capped_weights[2], 0.5, atol=1e-3), "asset 2 was the unconstrained winner and should now sit right at the cap"
Reference¶
reference/concepts/mean_variance_optimization.md
Promote¶
Compare with src/pm/optimization.py (mean_variance, minimum_variance).
Test¶
pytest tests/test_optimization.py
ORAL CHECK¶
Why can constraints improve a portfolio's out-of-sample behavior rather
than merely restricting it? Connect your answer to the sensitivity plot
above — what is a max_weight cap actually protecting against?
Try /tutor mean-variance optimization for an adaptive walkthrough.