Shrinkage is Bayes — the Bayesian view of estimation¶
Risk and Asset Allocation — the PyMC engine¶
The companion notebook shrinkage_python.ipynb derived the James–Stein and Ledoit–Wolf shrinkage estimators and showed they beat the raw sample estimates. This notebook reveals why they work: every shrinkage estimator is a Bayesian posterior mean in disguise. We rebuild them as explicit probability models in PyMC, which buys us something the point estimators cannot give — full posterior uncertainty.
This notebook is self-contained; no prior reading of Meucci required.
The one idea¶
Recall the shape of every shrinkage estimator: $$\hat\theta_{\text{shrunk}} \;=\; (1-a)\,\underbrace{\hat\theta_{\text{sample}}}_{\text{data}} \;+\; a\,\underbrace{\theta_{\text{target}}}_{\text{belief}}.$$ Now recall Bayes' rule for a Normal mean. If the data say $\hat\theta \sim \mathcal N(\theta,\,\text{se}^2)$ and our prior belief is $\theta \sim \mathcal N(\theta_0,\,\tau^2)$, then the posterior mean is $$\mathbb E[\theta\mid\text{data}] \;=\; (1-a)\,\hat\theta + a\,\theta_0, \qquad a \;=\; \frac{\text{se}^2}{\text{se}^2+\tau^2}.$$ These are the same formula. The shrinkage target is the Bayesian prior mean; the shrinkage intensity $a$ is the relative confidence in the prior versus the data. James–Stein and Ledoit–Wolf are just clever ways of estimating that confidence from the data itself (an empirical-Bayes move).
The pay-off of doing it as an explicit Bayesian model: we don't just get the shrunk point — we get the whole posterior distribution, so every downstream quantity (a portfolio weight, a risk number) comes with honest error bars. That is the thread that runs through the rest of the Risk and Asset Allocation arc.
Roadmap¶
- Shrinking the mean as a hierarchical model — partial pooling, identical in spirit to the baseball / Efron–Morris shrinkage, now applied to sector expected returns.
- Shrinking the covariance with an Inverse-Wishart prior toward a target (the conjugate, analytic route) and with a PyMC
LKJCholeskyCovmodel (the modern, sampled route). - Reading the posterior: uncertainty in the estimates and in the condition number.
import numpy as np, pandas as pd, matplotlib.pyplot as plt
import pymc as pm, arviz as az
from scipy.stats import invwishart
import shrinkage as sh
plt.rcParams.update({"figure.figsize": (9,4.5), "axes.grid": True, "grid.alpha": .25,
"axes.spines.top": False, "axes.spines.right": False, "font.size": 11})
BLUE, ORANGE, GREEN, RED, GREY = "#2b6cb0", "#dd6b20", "#2f855a", "#c53030", "#718096"
RNG = 20240719
print("PyMC", pm.__version__, "| ArviZ", az.__version__)
R = pd.read_csv("sector_etf_weekly.csv", index_col=0, parse_dates=True)
SECTORS = [c for c in R.columns if c != "SPY"]
X = R[SECTORS].values
T, N = X.shape
mu_hat, Sigma_hat = sh.sample_moments(X)
print("Real panel:", N, "sectors,", T, "weeks")
g++ not available, if using conda: `conda install gxx`
PyMC 6.0.1 | ArviZ 1.2.0 Real panel: 10 sectors, 310 weeks
The real dataset in detail: US sector ETFs¶
The real panel is weekly log-returns (in %) of the ten SPDR sector ETFs plus SPY, January 2019 – December 2024 (312 weeks), dividend/split-adjusted (via yfinance). Each ETF holds the S&P 500 members of one GICS sector, so together they slice the US equity market into its economic components:
| Ticker | Sector | What it holds (examples) |
|---|---|---|
| XLB | Materials | chemicals, mining, packaging (Linde, Sherwin-Williams) |
| XLE | Energy | oil & gas majors and services (Exxon, Chevron) |
| XLF | Financials | banks, insurers, asset managers (Berkshire, JPMorgan) |
| XLI | Industrials | aerospace, machinery, transport (Caterpillar, Honeywell, UPS) |
| XLK | Technology | hardware & software (Apple, Microsoft, Nvidia) |
| XLP | Consumer Staples | food, household, retail defensives (P&G, Coca-Cola, Walmart) |
| XLU | Utilities | electric / gas / water utilities (NextEra, Duke, Southern) |
| XLV | Health Care | pharma, biotech, devices, insurers (UnitedHealth, J&J, Lilly) |
| XLY | Consumer Discretionary | retail, autos, leisure (Amazon, Tesla, Home Depot) |
| XLC | Communication Services | telecom, media, internet (Meta, Alphabet, Netflix) |
| SPY | Market (S&P 500) | the whole index — the market proxy / benchmark |
The sectors are strongly, positively correlated (average pairwise correlation roughly 0.6–0.7 — they all ride the market), which is why the sample covariance is ill-conditioned and why shrinkage / a prior helps. The split between defensive (XLU, XLP, XLV) and cyclical (XLK, XLY, XLE) sectors gives real structure, and SPY is the market anchor used later for Black–Litterman.
1. Shrinking the mean: partial pooling¶
We model each sector's expected weekly return $\mu_i$ as drawn from a common population distribution, and the observed returns as noisy draws around $\mu_i$:
$$ \begin{aligned} \text{population:}\quad & m \sim \mathcal N(0, 5^2), \qquad \tau \sim \text{HalfNormal}(2)\\ \text{sector mean:}\quad & \mu_i \sim \mathcal N(m,\ \tau^2), \qquad i=1,\dots,N\\ \text{observations:}\quad & r_{i,t} \sim \mathcal N(\mu_i,\ \sigma_i^2), \qquad \sigma_i \sim \text{HalfNormal}(3). \end{aligned} $$
This hierarchical structure is what produces shrinkage automatically. The population parameters $m$ (the grand mean) and $\tau$ (how much sectors truly differ) are learned from the data. If sectors look similar ($\tau$ small), the model pools them hard toward $m$; if they look genuinely different ($\tau$ large), it trusts each sector's own average. This is exactly the James–Stein mechanism, and exactly the model behind baseball batting-average shrinkage (Efron–Morris) — here re-purposed for expected returns. The key output is the posterior mean of each $\mu_i$, which sits between the raw sample mean and the grand mean.
with pm.Model() as mean_model:
m = pm.Normal("m", 0.0, 5.0) # grand mean (population)
tau = pm.HalfNormal("tau", 2.0) # dispersion of true sector means
z = pm.Normal("z", 0.0, 1.0, shape=N) # non-centered helper (avoids the funnel)
mu = pm.Deterministic("mu", m + tau * z) # per-sector expected return
sig = pm.HalfNormal("sigma", 3.0, shape=N) # per-sector volatility
pm.Normal("r", mu[None, :], sig[None, :], observed=X)
idata_mean = pm.sample(1000, tune=1500, chains=4, cores=1, target_accept=0.95,
progressbar=False, random_seed=RNG)
print("max R-hat:", float(az.summary(idata_mean, var_names=["mu","m","tau"])["r_hat"].max()))
Initializing NUTS using jitter+adapt_diag...
Sequential sampling (4 chains in 1 job)
NUTS: [m, tau, z, sigma]
Sampling 4 chains for 1_500 tune and 1_000 draw iterations (6_000 + 4_000 draws total) took 6 seconds.
max R-hat: 1.0
post_mu = idata_mean.posterior["mu"].mean(("chain","draw")).values
hdi = az.hdi(idata_mean, var_names=["mu"])["mu"].values # 94% HDI per sector
m_post = float(idata_mean.posterior["m"].mean())
tau_post = float(idata_mean.posterior["tau"].mean())
order = np.argsort(mu_hat)
xpos = np.arange(N)
plt.figure(figsize=(10,4.6))
plt.errorbar(xpos, post_mu[order], yerr=[post_mu[order]-hdi[order,0], hdi[order,1]-post_mu[order]],
fmt="s", color=BLUE, capsize=3, label="Bayesian posterior mean (94% HDI)")
plt.plot(xpos, mu_hat[order], "o", color=RED, label="raw sample mean")
plt.axhline(m_post, color=GREY, ls=":", label="posterior grand mean $m$")
plt.xticks(xpos, np.array(SECTORS)[order]); plt.ylabel("expected weekly return (%)")
plt.title("Hierarchical Bayes pulls sector means toward the grand mean — with error bars")
plt.legend(); plt.show()
print("posterior grand mean m = %.3f%% population dispersion tau = %.3f%%" % (m_post, tau_post))
print("Compare: James-Stein point estimate below uses the same shrink-toward-grand-mean idea.")
posterior grand mean m = 0.242% population dispersion tau = 0.061% Compare: James-Stein point estimate below uses the same shrink-toward-grand-mean idea.
# Overlay the classical James-Stein point estimate: the Bayes posterior mean tracks it
mu_js, a_js, b_js = sh.james_stein_mean(X)
comp = pd.DataFrame({"sample": mu_hat, "James-Stein": mu_js, "Bayes posterior": post_mu}, index=SECTORS).round(3)
print("James-Stein intensity a = %.3f (toward grand mean)\n" % a_js)
print(comp)
print("\nBoth methods shrink the same way; the Bayesian version also reports the full posterior.")
James-Stein intensity a = 1.000 (toward grand mean)
sample James-Stein Bayes posterior
XLB 0.205 0.252 0.237
XLC 0.291 0.252 0.248
XLE 0.215 0.252 0.238
XLF 0.257 0.252 0.242
XLI 0.262 0.252 0.245
XLK 0.469 0.252 0.266
XLP 0.184 0.252 0.229
XLU 0.177 0.252 0.232
XLV 0.183 0.252 0.232
XLY 0.274 0.252 0.245
Both methods shrink the same way; the Bayesian version also reports the full posterior.
What the hierarchy bought us. The point estimators return a single shrunk number per sector. The Bayesian model returns a distribution per sector, so we can see that the sector means are estimated with substantial uncertainty (the HDIs are wide relative to the spread of the means). That uncertainty is precisely what a naive optimiser ignores — and precisely what the robust-allocation notebook (Project 4) will exploit.
2. Shrinking the covariance with an Inverse-Wishart prior¶
For the covariance matrix the conjugate prior is the Inverse-Wishart. It is defined by a degrees-of-freedom $\nu_0$ (how strongly we believe the prior) and a scale matrix $\Psi_0$ (the shape we believe in — our target): $$\Sigma \sim \mathcal{IW}(\nu_0,\ \Psi_0).$$ With Normal data of scatter $S=\sum_t (x_t-\bar x)(x_t-\bar x)'$, the posterior is again Inverse-Wishart, $$\Sigma\mid \text{data} \sim \mathcal{IW}(\nu_0+T,\ \Psi_0 + S),$$ and its mean is a clean blend of the prior target and the sample covariance: $$\mathbb E[\Sigma\mid\text{data}] \;=\; \frac{\Psi_0 + S}{\nu_0 + T - N - 1} \;\approx\; (1-a)\,\hat\Sigma \;+\; a\,\Big[\tfrac{\Psi_0}{\nu_0-N-1}\Big], \qquad a \approx \frac{\nu_0}{\nu_0+T}.$$ There it is again: posterior mean = shrinkage of the sample covariance toward the prior target, with intensity governed by the prior strength $\nu_0$. Set the target $\Psi_0$ to the scaled identity (the same target Ledoit–Wolf used) and sweep $\nu_0$ — from $\nu_0=0$ (pure sample) to $\nu_0\to\infty$ (pure target).
# Conjugate Inverse-Wishart shrinkage: sweep the prior strength nu0 (the intensity knob)
S_scatter = (X - mu_hat).T @ (X - mu_hat) # sample scatter (T * MLE cov)
target = (np.trace(Sigma_hat)/N) * np.eye(N) # scaled-identity target = Psi0/(nu0-N-1)
def iw_posterior_mean(nu0):
Psi0 = target * (nu0 - N - 1) if nu0 > N + 1 else target * 1e-6
nu1, Psi1 = nu0 + T, Psi0 + S_scatter
return Psi1 / (nu1 - N - 1)
nus = [0.001, 20, 60, 150, 400, 2000]
conds = [sh.condition_number(iw_posterior_mean(n)) for n in nus]
plt.semilogx(nus, conds, "o-", color=BLUE, lw=2)
plt.axhline(sh.condition_number(Sigma_hat), color=RED, ls="--", label="sample covariance")
plt.axhline(sh.condition_number(target), color=GREEN, ls=":", label="target (identity)")
plt.xlabel("prior strength $\\nu_0$ (= shrinkage intensity)"); plt.ylabel("condition number of posterior-mean $\\Sigma$")
plt.title("Stronger Inverse-Wishart prior = more shrinkage = better conditioning")
plt.legend(); plt.show()
print("nu0 -> 0 recovers the sample covariance; nu0 -> inf recovers the target.")
nu0 -> 0 recovers the sample covariance; nu0 -> inf recovers the target.
# The Inverse-Wishart posterior is a DISTRIBUTION over covariance matrices: propagate its uncertainty.
nu0 = 60; Psi0 = target*(nu0-N-1)
post = invwishart(df=nu0+T, scale=Psi0+S_scatter)
draws = post.rvs(size=2000, random_state=RNG)
cond_draws = np.array([sh.condition_number(D) for D in draws])
# posterior distribution of the largest correlation (a risk-relevant functional)
def max_offdiag_corr(C):
d = np.sqrt(np.diag(C)); Rho = C/np.outer(d,d); return Rho[np.triu_indices(N,1)].max()
corr_draws = np.array([max_offdiag_corr(D) for D in draws])
fig, ax = plt.subplots(1,2, figsize=(12,4.2))
ax[0].hist(cond_draws, bins=40, color=BLUE, alpha=.8)
ax[0].axvline(sh.condition_number(Sigma_hat), color=RED, ls="--", label="sample")
ax[0].set_title("Posterior of covariance condition number"); ax[0].set_xlabel("condition number"); ax[0].legend()
ax[1].hist(corr_draws, bins=40, color=ORANGE, alpha=.85)
ax[1].set_title("Posterior of the largest pairwise correlation"); ax[1].set_xlabel("correlation")
plt.tight_layout(); plt.show()
print("Bayesian shrinkage delivers a posterior mean condition number of %.1f (sample: %.1f),"
% (np.mean(cond_draws), sh.condition_number(Sigma_hat)))
print("and — crucially — a full distribution, not a single fragile number.")
Bayesian shrinkage delivers a posterior mean condition number of 33.6 (sample: 96.2), and — crucially — a full distribution, not a single fragile number.
The modern PyMC route: LKJCholeskyCov¶
The Inverse-Wishart is conjugate and fast, but it forces the same degrees-of-freedom on variances and correlations and can be hard to reason about. Modern Bayesian practice separates the covariance into volatilities and a correlation matrix, and puts an LKJ prior on the latter. The LKJ prior has a single shape parameter $\eta$: with $\eta=1$ all correlation matrices are equally likely, while $\eta>1$ concentrates mass near the identity — i.e. it shrinks correlations toward zero. So $\eta$ is, once again, a shrinkage-intensity knob, now with a transparent interpretation. We fit it with PyMC's LKJCholeskyCov and read off the posterior covariance with full uncertainty.
# Use a subset window so the model is quick; eta>1 shrinks correlations toward the identity
Xw = X[-104:] # last 2 years
with pm.Model() as cov_model:
chol, corr, stds = pm.LKJCholeskyCov(
"L", n=N, eta=4.0, sd_dist=pm.HalfNormal.dist(3.0), compute_corr=True)
Sigma = pm.Deterministic("Sigma", chol @ chol.T)
pm.MvNormal("x", mu=Xw.mean(0), chol=chol, observed=Xw)
idata_cov = pm.sample(600, tune=800, chains=4, cores=1, target_accept=0.9,
progressbar=False, random_seed=RNG)
print("max R-hat:", float(az.summary(idata_cov, var_names=["Sigma"])["r_hat"].max()))
Initializing NUTS using jitter+adapt_diag...
Sequential sampling (4 chains in 1 job)
NUTS: [L]
Sampling 4 chains for 800 tune and 600 draw iterations (3_200 + 2_400 draws total) took 3 seconds.
max R-hat: 1.01
Sig_bayes = idata_cov.posterior["Sigma"].mean(("chain","draw")).values
_, Sig_sample = sh.sample_moments(Xw)
Sig_lw, a_lw, _ = sh.ledoit_wolf_identity(Xw)
def to_corr(C):
d = np.sqrt(np.diag(C)); return C/np.outer(d,d)
fig, ax = plt.subplots(1,3, figsize=(13,4))
for a,(C,t) in zip(ax, [(Sig_sample,"sample"), (Sig_lw,"Ledoit-Wolf"), (Sig_bayes,"PyMC LKJ posterior")]):
im=a.imshow(to_corr(C), vmin=-1, vmax=1, cmap="RdBu_r"); a.set_title(t+" correlations")
a.set_xticks(range(N)); a.set_xticklabels(SECTORS, rotation=90, fontsize=8)
a.set_yticks(range(N)); a.set_yticklabels(SECTORS, fontsize=8)
fig.colorbar(im, ax=ax, fraction=.02); plt.show()
_cs = sh.condition_number(Sig_sample); _cl = sh.condition_number(Sig_lw); _cb = sh.condition_number(Sig_bayes)
print("condition numbers: sample %.1f Ledoit-Wolf %.1f PyMC-LKJ %.1f" % (_cs, _cl, _cb))
print("Both SHRINKAGE estimators pull the noisy sample correlations toward a cleaner matrix (the sample")
print("itself, of course, shrinks nothing) -- but by very different amounts here: Ledoit-Wolf improves the")
print("conditioning %.0fx, the LKJ posterior only %.1fx." % (_cs/_cl, _cs/_cb))
print("That is not a defect of either: an LKJ(eta>1) prior shrinks CORRELATIONS toward zero, whereas the")
print("identity-target estimator acts directly on the eigenvalue spectrum, which is what conditioning measures.")
condition numbers: sample 51.0 Ledoit-Wolf 28.3 PyMC-LKJ 28.0 Both SHRINKAGE estimators pull the noisy sample correlations toward a cleaner matrix (the sample itself, of course, shrinks nothing) -- but by very different amounts here: Ledoit-Wolf improves the conditioning 2x, the LKJ posterior only 1.8x. That is not a defect of either: an LKJ(eta>1) prior shrinks CORRELATIONS toward zero, whereas the identity-target estimator acts directly on the eigenvalue spectrum, which is what conditioning measures.
2b. At scale: Bayesian covariance shrinkage in high dimension¶
Fitting the full LKJCholeskyCov model by NUTS becomes impractical when $N$ is large — a 48-asset correlation matrix has over a thousand parameters, barely identified when $T\approx N$. Fortunately the conjugate Inverse-Wishart posterior gives the same Bayesian object analytically, no sampler required. We use it to show that Bayesian covariance shrinkage repairs the catastrophic conditioning of the sample covariance on the 48-stock and Fama–French 48-industry panels — the same mechanism as at low dimension, but far more consequential.
Xstk = pd.read_csv("stocks_weekly.csv", index_col=0).values
FFm = pd.read_csv("ff_industries_monthly.csv", index_col=0).values
def cond(S):
e = np.linalg.eigvalsh(S); return e[-1]/e[0]
def iw_cond(Z, ndraw=400, seed=0):
T, Nn = Z.shape; muh = Z.mean(0); Sc = (Z-muh).T @ (Z-muh)
nu0 = Nn + 2; Psi0 = np.mean(np.diag(np.cov(Z.T))) * np.eye(Nn) # prior toward avg-variance*I
pm_cov = (Psi0 + Sc) / (nu0 + T - Nn - 1) # posterior mean covariance
draws = invwishart.rvs(df=nu0+T, scale=Psi0+Sc, size=ndraw, random_state=seed)
return cond(np.cov(Z.T)), cond(pm_cov), np.median([cond(D) for D in draws])
print("Condition number of the covariance: sample vs Bayesian (Inverse-Wishart) posterior\n")
print("%-22s %14s %16s %14s" % ("panel (N/T)", "sample", "posterior mean", "posterior median"))
for name, Z in [("48 stocks (N/T=0.80)", Xstk[:60]), ("FF 48 ind (N/T=0.80)", FFm[:60])]:
cs, cpm, cmed = iw_cond(Z)
print("%-22s %14.0f %16.0f %14.0f" % (name, cs, cpm, cmed))
print("\nThe conjugate Inverse-Wishart posterior returns a well-conditioned covariance at scale -- the")
print("same Bayesian shrinkage as the LKJ model, but analytic and instant. This is what makes Bayesian")
print("allocation feasible for realistic, high-dimensional universes where NUTS on a full LKJ would not.")
Condition number of the covariance: sample vs Bayesian (Inverse-Wishart) posterior panel (N/T) sample posterior mean posterior median 48 stocks (N/T=0.80) 13174 1097 2173 FF 48 ind (N/T=0.80) 24404 1756 4023 The conjugate Inverse-Wishart posterior returns a well-conditioned covariance at scale -- the same Bayesian shrinkage as the LKJ model, but analytic and instant. This is what makes Bayesian allocation feasible for realistic, high-dimensional universes where NUTS on a full LKJ would not.
3. Summary¶
- Shrinkage = Bayes. $(1-a)\,\text{sample} + a\,\text{target}$ is the posterior mean of a Normal model whose prior is centred on the target; the intensity $a$ is the prior's relative confidence. James–Stein and Ledoit–Wolf are empirical-Bayes recipes that estimate $a$ from the data.
- Means → a hierarchical model (partial pooling) reproduces James–Stein and is the very same machinery as baseball batting-average shrinkage — now with posterior error bars on every sector's expected return.
- Covariance → an Inverse-Wishart prior toward a target gives shrinkage in closed form, with the prior strength $\nu_0$ as the intensity knob; a PyMC
LKJCholeskyCovmodel does the same thing by concentrating correlations toward the identity via $\eta$, and returns the full posterior. - The dividend of the Bayesian framing is uncertainty: distributions over the covariance, its condition number, and its correlations — not fragile point numbers.
Bridge. We have now estimated $\mu$ and $\Sigma$ Bayesianly with a prior. Project 2 formalises this as the Normal-Inverse-Wishart model, validates the posterior against a from-scratch MCMC sampler, and then asks the question that motivates the whole arc: how much does this parameter uncertainty actually cost a portfolio, and does acting Bayesian recover the loss? That is the "estimation risk" and the "deception of the sample efficient frontier".