Finance application — shrinking the factor zoo¶

Lasso / Elastic-Net factor selection in the cross-section of returns¶

Empirical asset pricing has a factor zoo problem: hundreds of "priced factors" have been published, most of them redundant repackagings of a few themes (value, momentum, profitability, low-risk…). Cochrane called it a "zoo of new factors"; the modern response is to let regularization pick the sparse subset that actually matters — Feng, Giglio & Xiu (2020), "Taming the Factor Zoo", and Kozak, Nagel & Santosh (2020), "Shrinking the Cross-Section". This notebook applies the Lasso/Elastic-Net machinery from the first notebook of this subsection to exactly that problem, on 60+ years of real factor returns.

The vehicle is the stochastic discount factor (SDF). Under a linear factor model the SDF is $M_t = 1 - b'(f_t - E f)$, and the tangency (maximum-Sharpe) portfolio weights are $b \propto \Sigma^{-1}\mu$ — the same object as the mean-variance portfolio of Shrinkage Estimation of Mean and Covariance and The Black–Litterman Model. A classic result (Britten-Jones, 1999) turns this into a regression: the tangency weights are the coefficients of regressing a vector of ones on the factor excess returns. That lets us drop in Ridge, Lasso and Elastic Net directly and read off a penalized, sparse SDF.

The through-lines: this is the finance face of the Lasso from reglm_python.ipynb; it is the frequentist cousin of the Bayesian treatment in Bayesian Estimation & Estimation Risk and The Black–Litterman Model; and the honest finding is a humbling one — the in-sample tangency is a mirage, and the zoo carries far fewer independent bets than its head-count suggests. Data: 20 long-short factors, monthly, 1963–2026 (Ken French data library).

1. The factor zoo — 20 long-short factors¶

What a "factor" actually is¶

Worth being concrete, because the word carries a lot of weight in this literature. A factor here is not a statistic computed on an index — it is itself the return on a portfolio: a monthly series in percent, and the data file is twenty such series side by side.

All but one are self-financing long–short portfolios. Ken French sorts every US stock on some characteristic, then buys the high group and sells the low group in equal dollar amounts. HML is long high book-to-market (value) and short low (growth); SMB long small caps, short large; Mom long the past year's winners, short its losers; RMW long robust profitability, short weak; CMA long conservative investors, short aggressive; and the Hi-minus-Lo anomaly series do the same with top-versus-bottom deciles. Because the short leg finances the long leg, the position costs roughly nothing to open, and what it earns is the premium attached to the characteristic, with the market's general ups and downs largely netted out. That is also why a factor can have a negative mean: it simply means the premium runs the other way, and you would want to hold the trade reversed.

The exception is Mkt-RF, which is long-only — the entire US market minus the risk-free rate. The RF column is that risk-free rate itself, carried in the file for reference rather than as a factor.

So the object constructed below is a portfolio of twenty trading strategies, not a portfolio of twenty stocks and not a tilt on an index. "Shrinking the factor zoo" means deciding how many of those strategies you actually need to run. The reason to expect the answer is "fewer than twenty" is that several are the same bet in different clothing: Value_BEME, Earn_EP, Cash_CFP and Div_DP are four different ways of asking whether a stock is cheap.

Twenty monthly long-short factor returns from the Ken French library, 1963–2026: the Fama-French five (market, size, value, profitability, investment), momentum and short/long-term reversal, plus a dozen anomaly long-shorts (Hi-minus-Lo deciles) on value, size, profitability, investment, earnings/price, cash-flow/price, dividend/price, accruals, net issuance, variance, residual variance and beta. Several are deliberately redundant — four different value proxies, three low-risk proxies — which is the whole point: a good selector should keep one of each theme, not all. The bar shows annualized mean premia (sign as reported; some anomalies pay to short the high-characteristic leg).

In [1]:
import numpy as np, pandas as pd, matplotlib.pyplot as plt, warnings
warnings.filterwarnings("ignore")
BLUE="#2b6cb0"; RED="#c53030"; GREEN="#2f855a"; ORANGE="#dd6b20"; GREY="#a0aec0"; PURP="#6b46c1"
p=pd.read_csv("factor_zoo.csv",index_col="ym"); fac=[c for c in p.columns if c!="RF"]
R=p[fac]; N=len(fac)
dates=pd.to_datetime(p.index.astype(str),format="%Y%m")
prem=R.mean()*12; sharpe1=R.mean()/R.std()*np.sqrt(12)
print(f"{N} factors, {len(R)} months, {p.index.min()//100}-{p.index.max()//100}")
fig,ax=plt.subplots(figsize=(11,4.2))
o=prem.sort_values(); ax.bar(range(N),o.values,color=[GREEN if v>0 else RED for v in o.values])
ax.set_xticks(range(N)); ax.set_xticklabels(o.index,rotation=90,fontsize=8); ax.axhline(0,color="k",lw=.7)
ax.set_ylabel("annualized mean premium (%)"); ax.set_title("The factor zoo — 20 long-short factors (1963-2026)")
plt.tight_layout(); plt.show()
print("Positive-premium factors pay to go long the high leg (Mom, HML, RMW...); negative ones pay to SHORT it")
print("(high investment, high issuance, high volatility underperform). The SDF can weight either direction.")
20 factors, 755 months, 1963-2026
No description has been provided for this image
Positive-premium factors pay to go long the high leg (Mom, HML, RMW...); negative ones pay to SHORT it
(high investment, high issuance, high volatility underperform). The SDF can weight either direction.

2. The zoo is redundant¶

The correlation heatmap shows the themes clumping — the value proxies (HML, Value_BEME, Earn_EP, Cash_CFP) move together; the low-risk proxies (Var, ResVar, Beta) form another block; investment and profitability overlap CMA and RMW. A principal-components scree puts a number on it: 10 components reach 90% of the covariation, where 20 independent series of this length would need 18. That is real redundancy, and it is what a sparse selector exploits — though it is a good deal less dramatic than the “the zoo is all one factor” telling. Redundancy is exactly what a sparse selector exploits — keep one representative per theme, drop the rest.

In [2]:
fig,ax=plt.subplots(1,2,figsize=(13,5))
C=R.corr().values
im=ax[0].imshow(C,cmap="RdBu_r",vmin=-1,vmax=1); ax[0].set_xticks(range(N)); ax[0].set_xticklabels(fac,rotation=90,fontsize=7)
ax[0].set_yticks(range(N)); ax[0].set_yticklabels(fac,fontsize=7); ax[0].set_title("Factor correlation matrix — themes clump")
plt.colorbar(im,ax=ax[0],fraction=0.046)
ev=np.linalg.eigvalsh(np.corrcoef((R-R.mean()).T))[::-1]; cum=np.cumsum(ev)/ev.sum()
ax[1].bar(range(1,N+1),ev,color=GREY); ax[1].set_xlabel("principal component"); ax[1].set_ylabel("eigenvalue",color=GREY)
a2=ax[1].twinx(); a2.plot(range(1,N+1),cum*100,"o-",color=BLUE,lw=2); a2.set_ylabel("cumulative variance (%)",color=BLUE)
a2.axhline(90,color=RED,ls=":",lw=1); k90=np.argmax(cum>=0.9)+1; a2.text(N*0.5,80,f"{k90} PCs explain 90%",color=RED,fontsize=9)
ax[1].set_title("PCA scree — few independent dimensions")
plt.tight_layout(); plt.show()
rp=np.random.default_rng(0)
def _k90(M):
    e=np.linalg.eigvalsh(np.corrcoef(M.T))[::-1]; return int(np.argmax(np.cumsum(e)/e.sum()>=0.9))+1
knull=np.mean([_k90(rp.standard_normal((len(R),N))) for _ in range(200)])
print(f"{k90} principal components are needed to reach 90% of the covariation among {N} factors. The benchmark that")
print(f"makes that number mean something: {N} INDEPENDENT series of the same length need {knull:.0f}. So the zoo does carry")
print(f"real redundancy -- but not the one-or-two-themes story sometimes told: PC1 alone is {100*cum[0]:.0f}%, and it still")
print(f"takes {int(np.argmax(cum>=0.75))+1} components to reach 75%. Enough duplication for a selector to exploit, not enough to call it a facade.")
No description has been provided for this image
10 principal components are needed to reach 90% of the covariation among 20 factors. The benchmark that
makes that number mean something: 20 INDEPENDENT series of the same length need 18. So the zoo does carry
real redundancy -- but not the one-or-two-themes story sometimes told: PC1 alone is 32%, and it still
takes 5 components to reach 75%. Enough duplication for a selector to exploit, not enough to call it a facade.

3. The in-sample mirage¶

Build the tangency SDF by the Britten-Jones regression — regress a vector of ones on the (standardized) factor excess returns, no intercept; the coefficients are the SDF/tangency weights. Fit it in-sample on 1963–2000 and it looks spectacular: an annualized Sharpe far above any single factor. But that number is manufactured by fitting $\Sigma^{-1}\mu$ to noise — with 20 correlated factors the sample covariance is near-singular and its inverse amplifies estimation error. Held out of sample on 2001–2026 the same weights deliver a fraction of the promised Sharpe. This is precisely the estimation-risk problem of Bayesian Estimation & Estimation Risk, met there with a prior on $\mu$ and $\Sigma$ and here with a penalty on the weights themselves. The Sharpe ratio, since everything below is measured in it. A portfolio's mean excess return divided by its volatility, $\text{SR}=\bar r/\sigma_r$, reported here annualized — multiply the monthly figure by $\sqrt{12}$. It asks how much return you are paid per unit of risk taken, and it is the natural yardstick for a tangency portfolio because the tangency portfolio is defined as the one that maximizes it. Two things make it readable. First, scale: a broad equity index earns roughly 0.4–0.5 over long samples, a genuinely good active strategy sustains something near 1, and a backtest reporting 2+ is usually a diagnosis rather than an achievement. Second, it is invariant to leverage — doubling every position doubles both mean and volatility and leaves the ratio unchanged — which is exactly why it can compare a 20-factor portfolio with a single-factor one on equal terms. Keep the 0.4–0.5 benchmark in mind for the number that comes next.

In [3]:
from sklearn.linear_model import LinearRegression, RidgeCV, LassoCV, ElasticNetCV, lasso_path
tr=p.index<=200012; te=p.index>200012
Rtr=R.values[tr]; Rte=R.values[te]; sd=Rtr.std(0); Ztr=Rtr/sd; Zte=Rte/sd; y=np.ones(len(Ztr))
def sharpe(x): return np.sqrt(12)*x.mean()/x.std()
bols=LinearRegression(fit_intercept=False).fit(Ztr,y).coef_
is_s=sharpe(Ztr@bols); oos_s=sharpe(Zte@bols)
print(f"Kitchen-sink tangency SDF ({N} factors):")
print(f"   IN-SAMPLE  Sharpe (1963-2000): {is_s:5.2f}   <- the mirage")
print(f"   OUT-OF-SAMPLE Sharpe (2001-26): {oos_s:5.2f}   <- the reality")
print(f"   best SINGLE factor OOS Sharpe : {max(sharpe(Zte[:,j]) for j in range(N)):5.2f}")
fig,ax=plt.subplots(figsize=(6.2,4.2))
ax.bar(["in-sample\n(1963-2000)","out-of-sample\n(2001-2026)"],[is_s,oos_s],color=[GREY,RED])
for i,v in enumerate([is_s,oos_s]): ax.text(i,v+0.03,f"{v:.2f}",ha="center")
ax.set_ylabel("annualized Sharpe"); ax.set_title("Kitchen-sink SDF: in-sample mirage vs OOS reality")
plt.tight_layout(); plt.show()
best_j=int(np.argmax([sharpe(Zte[:,j]) for j in range(N)]))
print("A Sharpe of ~2.4 in-sample is not skill -- it is Sigma^{-1}mu fitting noise across 20 correlated factors.")
print(f"Worth pausing on the third number: the 20-factor tangency portfolio ({oos_s:.2f}) is BEATEN out of sample by simply")
print(f"holding {fac[best_j]} on its own ({sharpe(Zte[:,best_j]):.2f}). Sixty years of data, twenty published factors, and the optimiser")
print("cannot outperform one of its own inputs -- that is how much damage estimation error does to Sigma^{-1}mu.")
Kitchen-sink tangency SDF (20 factors):
   IN-SAMPLE  Sharpe (1963-2000):  2.41   <- the mirage
   OUT-OF-SAMPLE Sharpe (2001-26):  0.40   <- the reality
   best SINGLE factor OOS Sharpe :  0.55
No description has been provided for this image
A Sharpe of ~2.4 in-sample is not skill -- it is Sigma^{-1}mu fitting noise across 20 correlated factors.
Worth pausing on the third number: the 20-factor tangency portfolio (0.40) is BEATEN out of sample by simply
holding Mkt-RF on its own (0.55). Sixty years of data, twenty published factors, and the optimiser
cannot outperform one of its own inputs -- that is how much damage estimation error does to Sigma^{-1}mu.

4. Lasso selection — taming the zoo¶

Now the penalized SDF. Sweeping the Lasso penalty traces a path from the full 20-factor kitchen sink down to a single factor; for each point we record how many factors survive, the (inflated) in-sample Sharpe, and the honest out-of-sample Sharpe. The picture is the overfitting scissors, though not quite in the textbook shape: in-sample Sharpe climbs with every added factor, while out-of-sample Sharpe rises to a peak around a dozen factors and then falls back. It is an inverted U, not an early plateau — the first ten factors genuinely buy out-of-sample performance, and only after that do further ones buy fit alone. The Lasso-selected sparse SDF (penalty by cross-validation) slightly beats the kitchen sink out of sample using half the factors, and its weights name the survivors. The pruning falls where the duplication is: of four value proxies only HML survives, and of three low-risk proxies only residual variance — while market, size, momentum, both reversals and the profitability/investment pair are kept.

In [4]:
alphas,coefs,_=lasso_path(Ztr,y,n_alphas=60,eps=1e-3)
nfac=(np.abs(coefs)>1e-8).sum(0); is_path=[sharpe(Ztr@coefs[:,k]) for k in range(coefs.shape[1])]
oos_path=[sharpe(Zte@coefs[:,k]) if np.any(coefs[:,k]) else 0 for k in range(coefs.shape[1])]
lc=LassoCV(n_alphas=100,cv=5,fit_intercept=False,max_iter=100000).fit(Ztr,y)
b_lasso=lc.coef_; k_l=int((np.abs(b_lasso)>1e-8).sum())
fig,ax=plt.subplots(1,2,figsize=(13.5,4.8))
ax[0].plot(nfac,is_path,"o-",color=GREY,lw=2,label="in-sample Sharpe")
ax[0].plot(nfac,oos_path,"o-",color=BLUE,lw=2,label="out-of-sample Sharpe")
ax[0].axvline(k_l,color=GREEN,ls="--",label=f"Lasso CV pick: {k_l} factors")
ax[0].set_xlabel("number of factors in the SDF"); ax[0].set_ylabel("annualized Sharpe"); ax[0].set_title("Overfitting scissors: fit rises, OOS plateaus"); ax[0].legend(fontsize=8)
srt=np.argsort(np.abs(b_lasso)); srt=srt[np.abs(b_lasso[srt])>1e-8]
ax[1].barh([fac[i] for i in srt],[b_lasso[i] for i in srt],color=[GREEN if b_lasso[i]>0 else RED for i in srt])
ax[1].set_xlabel("SDF weight (standardized factors)"); ax[1].set_title(f"The sparse SDF: {k_l} factors Lasso keeps")
plt.tight_layout(); plt.show()
oa=np.array(oos_path,float); pk=int(np.nanargmax(oa))
VAL=["HML","Value_BEME","Earn_EP","Cash_CFP","Div_DP"]; LOW=["Var","ResVar","Beta"]
kept=[fac[i] for i in srt[::-1]]
print(f"Lasso-CV SDF: {k_l} of {N} factors, OOS Sharpe {sharpe(Zte@b_lasso):.2f} against the kitchen sink's {oos_s:.2f} -- it does not")
print(f"merely match the 20-factor fit, it edges it, and the path peaks at {oa[pk]:.2f} with {nfac[pk]} factors. Cross-validation lands")
print("essentially on that peak without ever seeing the test period, which is the result worth having.")
print(f"Kept: {', '.join(kept)}.")
print(f"The pruning is exactly where the redundancy is: {sum(f in VAL for f in kept)} of the {len(VAL)} value proxies survives "
      f"({', '.join(f for f in kept if f in VAL)}) and {sum(f in LOW for f in kept)} of the {len(LOW)} low-risk proxies "
      f"({', '.join(f for f in kept if f in LOW)}).")
No description has been provided for this image
Lasso-CV SDF: 10 of 20 factors, OOS Sharpe 0.44 against the kitchen sink's 0.40 -- it does not
merely match the 20-factor fit, it edges it, and the path peaks at 0.45 with 12 factors. Cross-validation lands
essentially on that peak without ever seeing the test period, which is the result worth having.
Kept: Mom, ResVar, ST_Rev, SMB, Mkt-RF, LT_Rev, RMW, CMA, HML, Accr_AC.
The pruning is exactly where the redundancy is: 1 of the 5 value proxies survives (HML) and 1 of the 3 low-risk proxies (ResVar).

5. Out-of-sample, and the honest verdict¶

The cumulative out-of-sample return of the sparse Lasso SDF against the kitchen-sink SDF and a naive equal-weight (sign-corrected) combination. The sparse SDF tracks or beats the kitchen sink with a fraction of the moving parts. But the honest verdict is nuanced and worth stating plainly:

In [5]:
from sklearn.linear_model import RidgeCV
def norm(b): s=np.sum(np.abs(b)); return b/s if s>0 else b
w_ks=norm(bols); w_l=norm(b_lasso); w_eq=norm(np.sign(Rtr.mean(0)))
ret={"kitchen-sink OLS":Zte@w_ks,"Lasso sparse SDF":Zte@w_l,"equal-weight (signs)":Zte@w_eq}
dte=dates[te]
fig,ax=plt.subplots(figsize=(9,4.4))
for (nm,r_),c in zip(ret.items(),[GREY,BLUE,ORANGE]):
    ax.plot(dte,np.cumsum(r_),color=c,lw=2,label=f"{nm}  (OOS Sharpe {sharpe(r_):.2f})")
ax.set_ylabel("cumulative OOS return (%)"); ax.set_title("Out-of-sample SDF performance (2001-2026)"); ax.legend(fontsize=9); ax.grid(alpha=.3)
plt.tight_layout(); plt.show()
print("Verdict: the sparse Lasso SDF matches the 20-factor kitchen sink out of sample with ~half the factors -- parsimony")
print("at no cost, and a far more interpretable model. It does NOT dramatically beat OLS on Sharpe here: with 60 years of")
print("20 well-diversified factors the sample tangency is already decent. Shrinkage's OOS edge grows with the DIMENSION of")
print("the problem -- Kozak-Nagel-Santosh need hundreds of characteristic portfolios for it to dominate.")
No description has been provided for this image
Verdict: the sparse Lasso SDF matches the 20-factor kitchen sink out of sample with ~half the factors -- parsimony
at no cost, and a far more interpretable model. It does NOT dramatically beat OLS on Sharpe here: with 60 years of
20 well-diversified factors the sample tangency is already decent. Shrinkage's OOS edge grows with the DIMENSION of
the problem -- Kozak-Nagel-Santosh need hundreds of characteristic portfolios for it to dominate.

6. Summary¶

Regularization tames the factor zoo by turning SDF estimation into a penalized regression. Using the Britten-Jones trick (tangency weights = regressing ones on factor returns), the Lasso selects the sparse set of factors that span the opportunity set:

  • The zoo is redundant, measurably but not extravagantly — 10 principal components reach 90% of the covariation where 20 independent series would need 18; four value proxies and three low-risk proxies are near-duplicates. PC1 alone is only 32%.
  • The in-sample tangency is a mirage — an annualized Sharpe of ~2.4 in-sample collapses to 0.40 out of sample, because $\Sigma^{-1}\mu$ fits estimation noise across correlated factors. It is beaten out of sample by holding the market alone (0.55).
  • Lasso selects and prunes — the cross-validated sparse SDF keeps 10 of 20, dropping three of four value proxies and two of three low-risk proxies, and edges the kitchen sink out of sample (0.44 against 0.40). The out-of-sample path peaks at ~12 factors, and cross-validation finds that peak without seeing the test period.
  • Honest caveat — with 60 years of well-diversified factors, shrinkage does not beat OLS on out-of-sample Sharpe; its edge grows with dimensionality (Kozak–Nagel–Santosh, hundreds of portfolios). Selection's value here is a smaller, interpretable, more stable model, not a higher Sharpe.

Cross-links. This is the finance face of the Lasso built in Ridge, Lasso & Elastic Net. The SDF/tangency portfolio is the same $\Sigma^{-1}\mu$ object as in Shrinkage Estimation of Mean and Covariance, Bayesian Estimation & Estimation Risk and The Black–Litterman Model — where the identical instability is treated with a prior rather than a penalty, which is the cleanest illustration in the collection that the two are the same medicine. And Lasso = a Laplace prior on the SDF weights, tying back to the Variable Selection arc. Together they make the same point from two directions: raw sample mean-variance overfits, and the cure — Bayesian or penalized — is shrinkage. Data: Ken French data library, 1963–2026.