Skip to content

Commit 3c2becd

Browse files
committed
tutorial: warm-start + sparse inverse covariance + MOSEK appendix; split bootstrap; renumber
Content: - ch8 (DPP): add "warm starts" section — warm-start cuts solver time, DPP cuts compile time; orthogonal and complementary, with a runnable warm-start sweep verified to match the cold path. - ch9 (portfolio): make the efficient-frontier sweep a real Parameter sweep (build once, set value(gamma)) instead of rebuilding each gamma. - new ch10: Sparse Inverse Covariance Estimation (log_det + l1 budget, PSD variable), verified vs CVXR 1.9.1; base chol() sampling, no expm. - split the old combined "Showcase" chapter into ch12 Image Reconstruction (TV in-painting only) and ch13 The Bootstrap (expanded — general inference recipe for any psolve() estimator). - new Appendix A: Using a Commercial Solver (MOSEK) — get/use it plus a static, pre-rendered verbose example (genuine MOSEK 11.1.2 SDP log). Structure: - renumber all chapter files to sequential integers (drop 09a/10a/11a suffixes); appendix-mosek.qmd; References now follows the appendix. - code-overflow: scroll so long code lines get a horizontal scrollbar.
1 parent fddb1ed commit 3c2becd

12 files changed

Lines changed: 708 additions & 183 deletions

08-dpp.qmd

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,44 @@ dim(betas) # m coefficients x number of lambda values
154154
Whenever you plan to solve the *same shape* of problem many times --- a path, a
155155
grid search, a simulation, a bootstrap --- reach for a `Parameter`.
156156

157+
## The other half of the solve: warm starts
158+
159+
Recall that every solve splits into a **compile time** and a **solver time**. DPP
160+
attacks the *compile* time --- it reuses the canonicalized problem structure so we
161+
never re-stuff the matrices. But the solver still starts **cold** each time, refining
162+
an internal guess from scratch until it converges.
163+
164+
A **warm start** attacks the *other* half. A numerical solver is iterative; if you
165+
hand it a starting point already near the answer, it converges in fewer iterations.
166+
In a sweep over a fine grid the solution at one $\lambda$ is an excellent guess for
167+
the next, so the previous answer is exactly the start point we want. In CVXR that is
168+
one flag --- `warm_start = TRUE` uses the current variable values as the solver's
169+
initial point:
170+
171+
```{r}
172+
#| label: warm-sweep
173+
betas_warm <- sapply(lambda_vals, function(l) {
174+
value(lambda) <- l
175+
psolve(path_prob, warm_start = TRUE) # start from the previous lambda's solution
176+
value(beta)
177+
})
178+
max(abs(betas_warm - betas)) # same path, to solver tolerance
179+
```
180+
181+
The two ideas are **orthogonal and complementary**, not the same trick:
182+
183+
| | reuses | cuts | wins biggest when |
184+
|---|---|---|---|
185+
| **DPP** | the compiled problem structure | *compile* time | compilation dominates --- small problems solved *many* times |
186+
| **Warm start** | the previous solution | *solver* time | the solver dominates --- large/expensive problems, close-together solves |
187+
188+
DPP does nothing for the solver's iterations; a warm start does nothing for
189+
compilation. Each pays off precisely where the other does not, so a tight sweep wants
190+
**both** --- compile once (DPP), then step the solver from each previous answer
191+
(warm start). Warm-starting is solver-dependent (about 9 of CVXR's 15 solvers support
192+
it --- OSQP, SCS, Clarabel, MOSEK, Gurobi, PIQP, HiGHS, and more); a solver that
193+
cannot warm-start simply ignores the flag.
194+
157195
## Exercise
158196

159197
**Exercise (⭐).** The expression `gamma * p_norm(x, 1)` is DPP, but
@@ -182,5 +220,7 @@ parameter should be computed in R and stored in another parameter.
182220
CVXR compiles once and re-solves cheaply.
183221
- Use it for any **repeated solve** --- paths, grid searches, simulations. It is the
184222
efficient way to do the sweep you met in @sec-elastic-net.
223+
- **DPP cuts compile time; a warm start cuts solver time.** They are independent ---
224+
combine `Parameter`s with `warm_start = TRUE` to speed up both halves of a sweep.
185225

186226
For more, see the [DPP Tutorial](https://cvxr.rbind.io/examples/dpp/tutorial.html).

09-portfolio.qmd

Lines changed: 74 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ underlying models are due to @Markowitz:1952 (mean-variance) and
2626
#| include: false
2727
library(CVXR)
2828
library(ggplot2)
29+
library(RColorBrewer)
2930
source("_check_status.R")
3031
```
3132

@@ -63,19 +64,23 @@ colnames(R)
6364
## Trace the efficient frontier
6465

6566
For each $\gamma$ we solve the Markowitz problem and record the realized risk and
66-
return. As in @sec-elastic-net, this is the **same problem re-solved** over a grid ---
67-
exactly the place where the `Parameter` trick from @sec-dpp pays off.
67+
return. This is the **same problem re-solved** over a grid --- exactly where the
68+
`Parameter` trick from @sec-dpp pays off. We make $\gamma$ a `Parameter`, build the
69+
problem **once**, and each iteration just sets `value(gamma)` and re-solves, reusing
70+
the cached compilation instead of canonicalizing from scratch 40 times.
6871

6972
```{r}
7073
#| label: frontier
71-
w <- Variable(n)
72-
ret <- t(mu) %*% w
73-
risk <- quad_form(w, Sigma) # w' Sigma w, a convex quadratic
74-
cons <- list(w >= 0, sum(w) == 1)
74+
w <- Variable(n)
75+
ret <- t(mu) %*% w
76+
risk <- quad_form(w, Sigma) # w' Sigma w, a convex quadratic
77+
cons <- list(w >= 0, sum(w) == 1)
78+
gamma <- Parameter(nonneg = TRUE) # the risk-aversion knob, set per solve
79+
prob <- Problem(Maximize(ret - gamma * risk), cons) # built once
7580
7681
gammas <- 10^seq(-1, 3, length.out = 40)
7782
fr <- sapply(gammas, function(g) {
78-
prob <- Problem(Maximize(ret - g * risk), cons)
83+
value(gamma) <- g # swap in the new gamma; no rebuild
7984
psolve(prob)
8085
c(risk = sqrt(value(risk)), ret = value(ret), w = value(w))
8186
})
@@ -101,6 +106,36 @@ ggplot(df, aes(risk, ret)) +
101106
Every point on the blue curve is the best return achievable at that level of risk; the
102107
individual stocks (red) all sit *below* it --- diversification at work.
103108

109+
The frontier shows the risk-return *outcome*, but not *what the portfolio holds*. Since
110+
`fr` already captured the weight vector at every $\gamma$, we can read the composition
111+
straight back out and watch diversification happen. At a few points along the grid we
112+
stack each asset's share of the budget:
113+
114+
```{r}
115+
#| label: frontier-composition
116+
#| fig-cap: "Portfolio composition along the frontier. Small gamma concentrates the budget in a few assets; large gamma spreads it out --- diversification, made visible."
117+
markers <- c(1, 10, 20, 30, 40)
118+
wmat <- fr[grep("^w", rownames(fr)), markers] # 12 assets x 5 values of gamma
119+
rownames(wmat) <- names(mu)
120+
121+
comp <- data.frame(
122+
gamma = factor(rep(signif(gammas[markers], 2), each = n),
123+
levels = signif(gammas[markers], 2)),
124+
asset = factor(rep(names(mu), times = length(markers)), levels = names(mu)),
125+
weight = as.vector(wmat))
126+
127+
ggplot(comp, aes(gamma, weight, fill = asset)) +
128+
geom_col() +
129+
scale_fill_manual(values = brewer.pal(n, "Paired")) +
130+
labs(x = expression("Risk aversion " * gamma),
131+
y = "Fraction of budget", fill = "Asset") +
132+
theme_minimal()
133+
```
134+
135+
The left-most bar (greedy, low $\gamma$) leans on one or two names; as $\gamma$ grows the
136+
bars fragment into the full palette. That shift *is* the efficient frontier, seen from
137+
the portfolio's side.
138+
104139
## A different risk measure: CVaR
105140

106141
Variance penalizes upside and downside alike. **Conditional Value-at-Risk** (CVaR)
@@ -140,6 +175,38 @@ Same data, same `CVXR` machinery, a different notion of risk --- and a different
140175
typically more tail-aware, allocation. Switching risk measures is, once again, just a
141176
change to the objective.
142177

178+
To see *how* the allocation differs, hold the return target fixed and put the two
179+
side by side. The fair mean-variance counterpart minimizes variance subject to the same
180+
return floor, so both bars sit at the same expected return and only the risk measure
181+
changes:
182+
183+
```{r}
184+
#| label: cvar-vs-mv
185+
#| fig-cap: "Same return target, two risk measures. Variance and CVaR reallocate the budget differently."
186+
## mean-variance allocation at the SAME return target, for an apples-to-apples comparison
187+
wv_mv <- Variable(n)
188+
prob_mv <- Problem(Minimize(quad_form(wv_mv, Sigma)),
189+
list(sum(wv_mv) == 1, wv_mv >= 0, t(mu) %*% wv_mv >= target))
190+
psolve(prob_mv)
191+
check_solver_status(prob_mv)
192+
193+
comp2 <- data.frame(
194+
measure = factor(rep(c("Mean-variance", "Mean-CVaR"), each = n),
195+
levels = c("Mean-variance", "Mean-CVaR")),
196+
asset = factor(rep(names(mu), 2), levels = names(mu)),
197+
weight = c(value(wv_mv), value(wv)))
198+
199+
ggplot(comp2, aes(measure, weight, fill = asset)) +
200+
geom_col() +
201+
scale_fill_manual(values = brewer.pal(n, "Paired")) +
202+
labs(x = NULL, y = "Fraction of budget", fill = "Asset") +
203+
theme_minimal()
204+
```
205+
206+
Both portfolios earn the same expected return, yet CVaR --- caring only about the bad
207+
tail --- tilts the budget toward a different mix than variance, which penalizes upside
208+
and downside alike.
209+
143210
## Exercise
144211

145212
**Exercise (⭐).** Add a **diversification** rule to the mean-variance problem: no

10-invcov.qmd

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# Sparse Inverse Covariance Estimation {#sec-invcov}
2+
3+
<!-- LIVE. Adapted from cvxr_docs statistics/sparse-inverse-covariance-estimation.qmd
4+
(Fu & Narasimhan). Sampling uses base chol() instead of expm::sqrtm to keep
5+
dependencies light. Verified vs CVXR 1.9.1 (2026-06-10): true nonzeros = 24;
6+
alpha=10 -> 46, alpha=4 -> 24 (recovers truth), alpha=1 -> 12. -->
7+
8+
::: callout-note
9+
## What you'll be able to do
10+
Estimate the **graph** of conditional dependencies among variables --- which ones are
11+
related once you account for all the others --- by fitting a **sparse inverse
12+
covariance** matrix with a log-determinant objective and an $\ell_1$ budget.
13+
:::
14+
15+
```{r}
16+
#| label: setup
17+
#| include: false
18+
library(CVXR)
19+
library(Matrix)
20+
library(ggplot2)
21+
source("_check_status.R")
22+
```
23+
24+
## The idea
25+
26+
The portfolio chapter (@sec-portfolio) took a covariance matrix as given. Here we go
27+
the other way and **estimate** it --- and specifically its inverse, which carries the
28+
more interesting story.
29+
30+
For Gaussian data, the **inverse** covariance $S = \Sigma^{-1}$ (the *precision*
31+
matrix) encodes **conditional independence**: $S_{ij} = 0$ exactly when variables $i$
32+
and $j$ are independent *given all the others*. So a **sparse** $S$ is a sparse graph
33+
of direct relationships --- often what we actually want to discover. With more
34+
variables than the data can pin down, we impose that sparsity directly.
35+
36+
Given i.i.d. samples $x_i \sim N(0, \Sigma)$ with sample covariance $Q$, we maximize
37+
the Gaussian log-likelihood while capping the total size of $S$:
38+
39+
$$
40+
\begin{array}{ll}
41+
\underset{S}{\mbox{maximize}} & \log\det(S) - \mathop{\mathrm{tr}}(SQ) \\
42+
\mbox{subject to} & S \succeq 0, \qquad \sum_{i,j} |S_{ij}| \le \alpha .
43+
\end{array}
44+
$$
45+
46+
The $\ell_1$ budget $\alpha \ge 0$ controls sparsity: smaller $\alpha$ forces more
47+
off-diagonal entries to exactly zero. The objective is concave (`log_det` is concave;
48+
the trace term is linear) and the constraints are convex, so CVXR solves it directly.
49+
50+
## Synthetic data with a known answer
51+
52+
So we can check the estimate, we build a *true* sparse precision matrix $S_{\text{true}}$,
53+
invert it to get the covariance $R$, and sample from $N(0, R)$:
54+
55+
```{r}
56+
#| label: data
57+
set.seed(1)
58+
n <- 10 # number of variables
59+
m <- 1000 # number of samples
60+
61+
A <- rsparsematrix(n, n, 0.15, rand.x = rnorm)
62+
Strue <- as.matrix(A %*% t(A) + 0.05 * diag(n)) # sparse, symmetric, positive definite
63+
R <- solve(Strue) # the covariance to sample from
64+
65+
x <- matrix(rnorm(m * n), m, n) %*% chol(R) # rows are i.i.d. N(0, R)
66+
Q <- cov(x) # sample covariance
67+
68+
sum(abs(Strue) > 1e-4) # how many true nonzeros (of 100)
69+
```
70+
71+
## Fit at several sparsity budgets
72+
73+
A semidefinite variable is declared with `PSD = TRUE`. We sweep $\alpha$ and, after
74+
each solve, zero out numerically negligible entries to read off the recovered pattern:
75+
76+
```{r}
77+
#| label: fit
78+
alphas <- c(10, 6, 4, 1)
79+
S <- Variable(c(n, n), PSD = TRUE)
80+
obj <- Maximize(log_det(S) - matrix_trace(S %*% Q))
81+
82+
S_est <- lapply(alphas, function(a) {
83+
prob <- Problem(obj, list(sum(abs(S)) <= a))
84+
psolve(prob)
85+
check_solver_status(prob)
86+
Sa <- value(S)
87+
Sa[abs(Sa) <= 1e-4] <- 0 # treat tiny entries as exact zeros
88+
Sa
89+
})
90+
sapply(S_est, function(M) sum(M != 0)) # nonzeros recovered at each alpha
91+
```
92+
93+
Note `matrix_trace(S %*% Q)` rather than `sum(diag(S %*% Q))` --- same value, but the
94+
dedicated atom keeps the expression tree compact. And `log_det(S)` is its own atom:
95+
there is no `det()` for a CVXR variable, because the log-determinant is the convex
96+
object DCP understands.
97+
98+
## What the budget buys
99+
100+
The sparsity pattern of the estimate tightens as $\alpha$ shrinks. We plot the nonzero
101+
mask of the truth alongside each fit:
102+
103+
```{r}
104+
#| label: pattern-plot
105+
#| fig-width: 9
106+
#| fig-height: 2.4
107+
#| fig-cap: "Nonzero pattern (dark = nonzero) of the true precision matrix and the estimates. Smaller alpha -> sparser. Around alpha = 4 the estimate matches the truth's sparsity."
108+
mask_df <- function(M, lab) {
109+
data.frame(i = rep(seq_len(n), n), j = rep(seq_len(n), each = n),
110+
nz = factor(as.integer(as.vector(M) != 0)), panel = lab)
111+
}
112+
panels <- rbind(
113+
mask_df(Strue, "truth"),
114+
do.call(rbind, Map(function(M, a) mask_df(M, paste0("alpha = ", a)), S_est, alphas)))
115+
panels$panel <- factor(panels$panel, levels = c("truth", paste0("alpha = ", alphas)))
116+
117+
ggplot(panels, aes(i, j, fill = nz)) +
118+
geom_tile(color = "grey80") +
119+
scale_fill_manual(values = c("0" = "white", "1" = "steelblue"), guide = "none") +
120+
scale_y_reverse() + coord_equal() +
121+
facet_wrap(~ panel, nrow = 1) +
122+
theme_void() + theme(strip.text = element_text(margin = margin(4, 0, 4, 0)))
123+
```
124+
125+
At $\alpha = 10$ the budget is loose and the estimate is dense; as $\alpha$ falls the
126+
off-diagonal entries are squeezed to zero, and near $\alpha = 4$ the recovered pattern
127+
matches the true number of nonzeros. Too small ($\alpha = 1$) and we lose real edges.
128+
In practice you would pick $\alpha$ by cross-validation --- and because the only thing
129+
changing across the sweep is one constant, this is a textbook case for the `Parameter`
130+
trick of @sec-dpp.
131+
132+
## Takeaways
133+
134+
- The **inverse** covariance is the object of interest: its zeros are **conditional
135+
independencies** --- a graph of direct relationships.
136+
- `log_det(S) - matrix_trace(S %*% Q)` with `S` declared `PSD = TRUE` and an
137+
$\ell_1$ budget is the whole model --- a few lines of CVXR.
138+
- One knob, $\alpha$, trades density for sparsity; sweep it with a `Parameter`.
139+
140+
See [Sparse Inverse Covariance Estimation](https://cvxr.rbind.io/examples/statistics/sparse-inverse-covariance-estimation.html)
141+
for the full treatment.
File renamed without changes.

0 commit comments

Comments
 (0)