High-dimensional portfolios — when shrinkage becomes essential¶

Covariance shrinkage & sparse asset selection on 48 stocks, using the Asset-Risk data¶

The factor-selection notebook ended on an honest cliffhanger: with 20 diversified factors over 60 years, shrinkage did not beat OLS out of sample, because that problem is not high-dimensional. This notebook supplies the regime where shrinkage is not optional but essential — and it does so on the Asset-Risk arc's own data: 48 individual stocks, weekly.

The task is the minimum-variance portfolio, $\;\min_w w'\Sigma w\;$ s.t. $\;\mathbf 1'w=1$, with solution $w\propto\Sigma^{-1}\mathbf 1$. The catch is $\Sigma$: with 48 assets it has $48\cdot 49/2 = 1{,}176$ free parameters, and estimating it from a short rolling window (say one year, 52 weeks) means more parameters than observations. The sample covariance is then near-singular, $\Sigma^{-1}$ explodes, and the "optimal" portfolio takes wild offsetting long/short positions that are pure estimation noise — Markowitz's own optimisation turned into an error-maximiser (Michaud, 1989).

The cures are the same shrinkage ideas from this subsection, in their asset-risk form:

  • Ledoit–Wolf linear covariance shrinkage — pull the noisy sample $\Sigma$ toward a structured target (the asset-risk standard; Ledoit & Wolf, 2004).
  • Ridge (L2) on the covariance — $\Sigma+\gamma I$, the direct L2 analogue.
  • Long-only / sparse (L1) min-variance — the no-short constraint acts as implicit L1 shrinkage and selects a sparse set of assets (Jagannathan & Ma, 2003; Brodie et al., 2009) — the asset-selection cousin of factor selection.
  • Equal-weight $1/N$ — the humbling benchmark that is hard to beat (DeMiguel, Garlappi & Uppal, 2009).

This is the frequentist face of the Bayesian treatment in Shrinkage Estimation of Mean and Covariance, Bayesian Estimation & Estimation Risk and The Black–Litterman Model, and the portfolio face of the Variable Selection arc's L1. Data: the same 48 stocks, weekly, used across the Risk & Asset Allocation section.

1. The data and the minimum-variance objective¶

48 large-cap US stocks, weekly returns (the Asset-Risk arc's stocks_weekly.csv). We work with the minimum-variance portfolio because it depends only on the covariance $\Sigma$ — no need to estimate expected returns (notoriously hard) — so it isolates the covariance-estimation problem cleanly. All results are out-of-sample: at each date we estimate $\Sigma$ on a trailing window, form the portfolio, and hold it for the next week.

In [1]:
import numpy as np, pandas as pd, matplotlib.pyplot as plt, warnings, time
warnings.filterwarnings("ignore")
from sklearn.covariance import LedoitWolf
from scipy.optimize import minimize
BLUE="#2b6cb0"; RED="#c53030"; GREEN="#2f855a"; ORANGE="#dd6b20"; GREY="#a0aec0"; PURP="#6b46c1"
d=pd.read_csv("stocks_weekly.csv",index_col=0); R=d.values/100.0; N=R.shape[1]; tickers=list(d.columns)
dates=pd.to_datetime(d.index)
print(f"{R.shape[0]} weeks x {N} stocks ({dates.min().date()} to {dates.max().date()})")
print(f"Covariance has N(N+1)/2 = {N*(N+1)//2} free parameters -- vs a 1-year window of only 52 observations.")
print("Tickers:", ", ".join(tickers))
312 weeks x 48 stocks (2019-01-08 to 2024-12-24)
Covariance has N(N+1)/2 = 1176 free parameters -- vs a 1-year window of only 52 observations.
Tickers: AAPL, ABBV, ABT, ADBE, AMD, AMZN, AVGO, AXP, BA, BAC, BLK, C, CAT, COP, COST, CRM, CSCO, CVX, GE, GOOGL, GS, HD, HON, INTC, JNJ, JPM, KO, LLY, LMT, LOW, MCD, META, MRK, MS, MSFT, NKE, NVDA, ORCL, PEP, PFE, PG, TMO, TSLA, UNH, UPS, WFC, WMT, XOM

2. The blow-up — sample Markowitz in high dimensions¶

Estimate $\Sigma$ on a single one-year window and form the sample minimum-variance portfolio. Because $N=48$ is close to the 52 observations, the inverse covariance is unstable and the weights explode into enormous offsetting long/short bets — gross leverage in the double digits — that have nothing to do with real risk structure. The left panel shows the weights; the right traces out-of-sample volatility as the estimation window shrinks: the sample portfolio's OOS risk rises catastrophically as $N/T\to 1$, while a shrunk estimator stays tame. This is the high-dimensional curse the factor notebook lacked.

In [2]:
def minvar(S):
    iv=np.linalg.solve(S, np.ones(N)); return iv/iv.sum()
w_samp=minvar(np.cov(R[:52].T))
fig,ax=plt.subplots(1,2,figsize=(13.5,4.6))
o=np.argsort(w_samp); ax[0].bar(range(N),w_samp[o],color=[RED if v<0 else BLUE for v in w_samp[o]])
ax[0].set_title(f"Sample min-var weights, 1-yr window (gross leverage {np.abs(w_samp).sum():.1f}x)")
ax[0].set_xlabel("stock (sorted)"); ax[0].set_ylabel("portfolio weight"); ax[0].axhline(0,color="k",lw=.6)
# OOS vol vs window length: sample vs Ledoit-Wolf
def roll_vol(win, estim):
    r=[]
    for k in range(win,len(R)-1):
        S=estim(R[k-win:k]); r.append(minvar(S)@R[k])
    return np.std(r)*np.sqrt(52)
# T must EXCEED N: a covariance from T observations has rank <= T-1, so at T <= 48 it is exactly
# singular and the minimum-variance portfolio is undefined. np.linalg.solve does not raise there --
# floating-point noise makes a rank-44 matrix look invertible (condition number ~1e18) and it returns
# garbage. Plotting those points would dramatise the curse with numbers that are not estimates at all.
wins=list(range(N+2,205,15))
vs=[roll_vol(w, lambda X:np.cov(X.T)) for w in wins]
vl=[roll_vol(w, lambda X:LedoitWolf().fit(X).covariance_) for w in wins]
ax[1].plot(wins,np.array(vs)*100,"o-",color=RED,lw=2,label="sample covariance")
ax[1].plot(wins,np.array(vl)*100,"o-",color=BLUE,lw=2,label="Ledoit-Wolf shrinkage")
ax[1].axvline(N,color="k",ls=":",lw=1); ax[1].text(N+2,max(vs)*80,f"N={N} assets",fontsize=8)
ax[1].set_xlabel("estimation window (weeks)"); ax[1].set_ylabel("OOS annualized vol (%)"); ax[1].set_title("The N/T curse: sample risk explodes at short windows"); ax[1].legend()
plt.tight_layout(); plt.show()
c52=np.linalg.cond(np.cov(R[:52].T)); c150=np.linalg.cond(np.cov(R[:150].T))
print(f"At a 1-year window the sample min-var portfolio carries {np.abs(w_samp).sum():.0f}x gross leverage, and its OOS")
print(f"volatility climbs from {vs[-1]*100:.0f}% at the longest window to {vs[0]*100:.0f}% as T approaches N=48, while Ledoit-Wolf stays")
print(f"flat near {np.mean(vl)*100:.0f}% throughout. The mechanism is visible in the conditioning: cond(Sigma) is {c150:.0e} at a")
print(f"150-week window and {c52:.0e} at 52 weeks, and at T<=N it is infinite -- the matrix has no inverse to take.")
No description has been provided for this image
At a 1-year window the sample min-var portfolio carries 14x gross leverage, and its OOS
volatility climbs from 12% at the longest window to 61% as T approaches N=48, while Ledoit-Wolf stays
flat near 13% throughout. The mechanism is visible in the conditioning: cond(Sigma) is 9e+02 at a
150-week window and 5e+04 at 52 weeks, and at T<=N it is infinite -- the matrix has no inverse to take.

3. The horse race — shrinkage, sparsity, and 1/N¶

A proper rolling backtest (one-year window, re-estimated weekly) of five portfolios: sample min-variance, Ledoit–Wolf shrinkage, ridge (L2) covariance, long-only sparse (L1) min-variance, and equal-weight $1/N$. We report the metrics a risk desk actually cares about — out-of-sample annualized volatility (the objective), Sharpe, average gross leverage, and turnover — plus, for the sparse portfolio, how many of the 48 stocks it actually holds. (The Sharpe ratio is mean excess return per unit of volatility, $\text{SR}=\bar r/\sigma_r$, annualized by $\sqrt{52}$ for weekly data — see the factor-selection notebook for a fuller treatment. Note that it is *not the objective here: these portfolios minimise variance and never look at expected returns at all, so volatility is the column to judge them on and Sharpe is a side effect worth reporting but not optimising.)*

In [3]:
def longonly_minvar(S):
    r=minimize(lambda w: w@S@w, np.ones(N)/N, method="SLSQP", bounds=[(0,1)]*N,
               constraints=({"type":"eq","fun":lambda w:w.sum()-1},), options={"maxiter":200,"ftol":1e-11})
    return r.x
def ridge_minvar(X): S=np.cov(X.T); return minvar(S+0.1*np.trace(S)/N*np.eye(N))
methods={"sample":lambda X:minvar(np.cov(X.T)),
         "Ledoit-Wolf":lambda X:minvar(LedoitWolf().fit(X).covariance_),
         "ridge (L2)":ridge_minvar,
         "long-only (sparse L1)":lambda X:longonly_minvar(np.cov(X.T)),
         "equal 1/N":lambda X:np.ones(N)/N}
win=52; res={m:{"ret":[],"lev":[],"w":[]} for m in methods}; nz=[]
for k in range(win,len(R)-1):
    X=R[k-win:k]
    for m,f in methods.items():
        w=f(X); res[m]["ret"].append(float(w@R[k])); res[m]["lev"].append(float(np.abs(w).sum())); res[m]["w"].append(w)
    nz.append(int((res["long-only (sparse L1)"]["w"][-1]>1e-4).sum()))
def ann_vol(r): return np.std(r)*np.sqrt(52)*100
def ann_sh(r): return np.mean(r)/np.std(r)*np.sqrt(52)
def turn(ws): W=np.array(ws); return np.mean(np.abs(np.diff(W,axis=0)).sum(1))
tab=pd.DataFrame({m:{"OOS vol %":ann_vol(res[m]["ret"]),"OOS Sharpe":ann_sh(res[m]["ret"]),
                     "gross lev":np.mean(res[m]["lev"]),"turnover":turn(res[m]["w"])} for m in methods}).T
print(tab.round(2).to_string())
print(f"\nlong-only sparse portfolio holds on average {np.mean(nz):.0f} of {N} stocks (the rest get zero weight).")
rngb=np.random.default_rng(0); T=len(res["equal 1/N"]["ret"])
def bootgap(a,b,stat,B=2000):
    A=np.array(res[a]["ret"]); C=np.array(res[b]["ret"])
    g=np.array([stat(A[i])-stat(C[i]) for i in rngb.integers(0,T,(B,T))])
    return g
gs=bootgap("long-only (sparse L1)","equal 1/N",ann_sh)
gv=bootgap("long-only (sparse L1)","equal 1/N",ann_vol)
gl=bootgap("long-only (sparse L1)","Ledoit-Wolf",ann_vol)
d_sh=ann_sh(res["long-only (sparse L1)"]["ret"])-ann_sh(res["equal 1/N"]["ret"])
d_vo=ann_vol(res["long-only (sparse L1)"]["ret"])-ann_vol(res["equal 1/N"]["ret"])
d_lw=ann_vol(res["long-only (sparse L1)"]["ret"])-ann_vol(res["Ledoit-Wolf"]["ret"])
print(f"\nSparse long-only against 1/N, with a paired bootstrap over the {T} weeks rather than an eyeball:")
print(f"   Sharpe     {d_sh:+.3f}  95% CI [{np.percentile(gs,2.5):+.3f}, {np.percentile(gs,97.5):+.3f}]  P(better) = {(gs>0).mean():.2f}")
print(f"   volatility {d_vo:+.2f}pp 95% CI [{np.percentile(gv,2.5):+.2f}, {np.percentile(gv,97.5):+.2f}]")
print(f"   vs Ledoit-Wolf, volatility {d_lw:+.2f}pp 95% CI [{np.percentile(gl,2.5):+.2f}, {np.percentile(gl,97.5):+.2f}]")
print("So: on RISK-ADJUSTED RETURN the sparse portfolio and 1/N are indistinguishable -- the CI straddles zero and the")
print("coin lands heads half the time. On VOLATILITY, which is the quantity these portfolios actually minimise, the")
print("advantage over 1/N is real and the tie with Ledoit-Wolf is genuine. Judge an estimator on its own objective.")
                       OOS vol %  OOS Sharpe  gross lev  turnover
sample                     46.00       -0.07      15.10      7.85
Ledoit-Wolf                15.50        0.36       2.15      0.33
ridge (L2)                 16.26        0.21       2.65      0.48
long-only (sparse L1)      15.00        0.73       1.00      0.20
equal 1/N                  20.49        0.70       1.00      0.00

long-only sparse portfolio holds on average 12 of 48 stocks (the rest get zero weight).

Sparse long-only against 1/N, with a paired bootstrap over the 259 weeks rather than an eyeball:
   Sharpe     +0.028  95% CI [-0.584, +0.606]  P(better) = 0.53
   volatility -5.49pp 95% CI [-8.25, -2.80]
   vs Ledoit-Wolf, volatility -0.51pp 95% CI [-1.51, +0.42]
So: on RISK-ADJUSTED RETURN the sparse portfolio and 1/N are indistinguishable -- the CI straddles zero and the
coin lands heads half the time. On VOLATILITY, which is the quantity these portfolios actually minimise, the
advantage over 1/N is real and the tie with Ledoit-Wolf is genuine. Judge an estimator on its own objective.

4. Reading the race¶

The bars make the story visual: the sample portfolio has by far the highest out-of-sample volatility and leverage — the optimiser maximised estimation error. Every shrinkage/constraint estimator collapses OOS volatility and leverage to sane levels. Ledoit–Wolf and the long-only sparse portfolio are essentially tied for the lowest OOS volatility (~15%), and the sparse one gets there holding only a dozen stocks, at the lowest turnover. On the objective these portfolios are actually built to minimise, both beat $1/N$ by about 5 percentage points, and the bootstrap below confirms that gap is well outside noise.

On Sharpe the picture is different and the notebook resists overclaiming it. The sparse portfolio's 0.73 against $1/N$'s 0.70 looks like a win, but a paired bootstrap puts the gap's 95% interval at roughly $[-0.6, +0.6]$ — a coin flip. DeMiguel, Garlappi & Uppal's result stands: with zero estimation and no optimisation at all, $1/N$ matches every one of these estimators on risk-adjusted return. What the estimators genuinely deliver is lower risk, not more return per unit of it.

In [4]:
fig,ax=plt.subplots(1,3,figsize=(15,4.4)); ms=list(methods)
cols=[RED,BLUE,GREEN,PURP,GREY]
ax[0].bar(ms,[ann_vol(res[m]["ret"]) for m in ms],color=cols); ax[0].set_ylabel("OOS annualized vol (%)"); ax[0].set_title("Out-of-sample volatility (objective)")
ax[1].bar(ms,[np.mean(res[m]["lev"]) for m in ms],color=cols); ax[1].set_ylabel("avg gross leverage"); ax[1].set_title("Leverage (|weights| sum)")
ax[2].bar(ms,[ann_sh(res[m]["ret"]) for m in ms],color=cols); ax[2].set_ylabel("OOS Sharpe"); ax[2].set_title("Risk-adjusted return")
for a in ax: plt.setp(a.get_xticklabels(),rotation=25,ha="right",fontsize=8)
plt.tight_layout(); plt.show()
print("Shrinkage/constraints cut OOS volatility and leverage by an order of magnitude vs sample Markowitz. Ledoit-Wolf and")
print("the sparse long-only portfolio tie for the lowest OOS volatility (~15%); the sparse one uses far fewer holdings/turnover.")
No description has been provided for this image
Shrinkage/constraints cut OOS volatility and leverage by an order of magnitude vs sample Markowitz. Ledoit-Wolf and
the sparse long-only portfolio tie for the lowest OOS volatility (~15%); the sparse one uses far fewer holdings/turnover.

5. The sparse portfolio — asset selection¶

The long-only constraint is not just stabilising; it is selecting. Jagannathan & Ma (2003) showed the no-short-sale constraint is mathematically equivalent to shrinking the covariance, and it drives most weights to exactly zero — an L1 effect. The result is a concentrated, interpretable portfolio in a dozen or so low-covariance names, re-selected as conditions change. This is the direct analogue of the factor-selection notebook: there we selected factors from a zoo; here we select assets from the universe, and the mechanism — L1/shrinkage — is the same.

In [5]:
wl=longonly_minvar(np.cov(R[-52:].T))
held=np.where(wl>1e-4)[0]; ho=held[np.argsort(-wl[held])]
fig,ax=plt.subplots(1,2,figsize=(13,4.4))
ax[0].bar([tickers[i] for i in ho],[wl[i] for i in ho],color=PURP)
ax[0].set_title(f"Sparse long-only portfolio (latest year): {len(held)}/{N} stocks held"); ax[0].set_ylabel("weight")
plt.setp(ax[0].get_xticklabels(),rotation=90,fontsize=8)
ax[1].hist(nz,bins=range(min(nz),max(nz)+2),color=PURP,edgecolor="white",align="left")
ax[1].axvline(np.mean(nz),color=RED,lw=2,label=f"avg {np.mean(nz):.0f} held"); ax[1].set_xlabel("# stocks held"); ax[1].set_ylabel("weeks"); ax[1].set_title(f"Sparsity over the backtest (of {N} stocks)"); ax[1].legend()
plt.tight_layout(); plt.show()
print(f"The sparse portfolio concentrates in ~{np.mean(nz):.0f} of {N} names and zeroes the rest -- asset selection by L1,")
print("the portfolio counterpart of the factor selection in the previous notebook.")
No description has been provided for this image
The sparse portfolio concentrates in ~12 of 48 names and zeroes the rest -- asset selection by L1,
the portfolio counterpart of the factor selection in the previous notebook.

6. Summary¶

Give a portfolio optimiser more assets than data and it maximises estimation error, not diversification — the high-dimensional regime the factor notebook lacked. On 48 stocks with a one-year window ($N/T\approx 0.9$), the sample minimum-variance portfolio ran to double-digit gross leverage and multiples of the sensible out-of-sample volatility. The cures are shrinkage:

method OOS vol leverage note
sample min-variance highest (~46%) ~15× error-maximiser
Ledoit–Wolf shrinkage ≈lowest (~15.5%) ~2× the asset-risk standard
ridge (L2) covariance low (~16%) ~2.5× direct L2 analogue
long-only sparse (L1) ≈lowest (~15%) 1× (no shorts) selects ~12/48; Sharpe tied with 1/N
equal-weight 1/N moderate (~20%) 1× strong Sharpe, no estimation

This closes the loop opened by the factor-selection notebook: shrinkage's payoff scales with dimensionality, and portfolio construction is where it is indispensable. It is the frequentist twin of Shrinkage Estimation of Mean and Covariance, Bayesian Estimation & Estimation Risk and The Black–Litterman Model, which treat exactly this $\Sigma^{-1}$ instability with a prior instead of a penalty; and of the Variable Selection arc, since the long-only/L1 portfolio selects assets just as the Lasso selects factors. The honest coda is DeMiguel et al.'s, and it survives being tested rather than repeated: against all this machinery, naive $1/N$ is statistically indistinguishable on Sharpe — the bootstrapped 95% interval for the gap straddles zero. Where the estimators do win, decisively and verifiably, is on the objective they actually optimise: about 5 percentage points of annualised volatility. The lesson is not that shrinkage beats $1/N$ outright, but that in high dimensions estimating less is what makes estimation work at all — and that an estimator should be judged on the quantity it was built to control.

Data: 48 US stocks, weekly, from the Asset-Risk arc (stocks_weekly.csv).