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¶

  1. Shrinking the mean as a hierarchical model — partial pooling, identical in spirit to the baseball / Efron–Morris shrinkage, now applied to sector expected returns.
  2. Shrinking the covariance with an Inverse-Wishart prior toward a target (the conjugate, analytic route) and with a PyMC LKJCholeskyCov model (the modern, sampled route).
  3. Reading the posterior: uncertainty in the estimates and in the condition number.
In [1]:
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.

In [2]:
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
In [3]:
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.")
No description has been provided for this image
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.
In [4]:
# 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).

In [5]:
# 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.")
No description has been provided for this image
nu0 -> 0 recovers the sample covariance; nu0 -> inf recovers the target.
In [6]:
# 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.")
No description has been provided for this image
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.

In [7]:
# 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
In [8]:
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.")
No description has been provided for this image
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.

In [9]:
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 LKJCholeskyCov model 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".