Finance factor selection (R) — glmnet¶

Shrinking the factor zoo with a penalized SDF¶

The R cross-check for the factor-selection notebook. Using the Britten-Jones (1999) result — the tangency / SDF weights are the coefficients of regressing a vector of ones on the factor excess returns — we fit the penalized SDF with glmnet and let the Lasso select the sparse set of factors that span the opportunity set (Feng-Giglio-Xiu 2020; Kozak-Nagel-Santosh 2020). Same 20 long-short factors, 1963-2026 (Ken French, factor_zoo.csv). This is the finance face of reglm_R.ipynb's glmnet, and the frequentist cousin of the Bayesian treatment in Shrinkage Estimation of Mean and Covariance and Bayesian Estimation & Estimation Risk.

In [1]:
options(repr.plot.width=12, repr.plot.height=4.4)
.libPaths(c("C:/Users/user/R/win-library/4.6", .libPaths()))
suppressMessages(library(glmnet))
p<-read.csv("factor_zoo.csv"); fac<-setdiff(names(p),c("ym","RF")); R<-as.matrix(p[,fac]); N<-length(fac)
sharpe<-function(x) sqrt(12)*mean(x)/sd(x)
cat(sprintf("glmnet %s | %d factors x %d months (%d-%d)\n", as.character(packageVersion("glmnet")), N, nrow(R), min(p$ym)%/%100, max(p$ym)%/%100))
glmnet 5.0 | 20 factors x 755 months (1963-2026)

1. The factor zoo and its redundancy¶

Each column of factor_zoo.csv is the return on a portfolio, monthly and in percent — not a statistic computed on an index. All but one are self-financing long–short portfolios: Ken French sorts every US stock on a characteristic and buys the high group while selling the low group, so HML is long value and short growth, Mom long past winners and short losers, and the Hi-minus-Lo series do the same across deciles. The short leg finances the long leg, so what the series earns is the premium attached to the characteristic with market movement netted out — and a negative mean simply means the premium runs the other way. Mkt-RF is the exception, long-only: the whole market minus the risk-free rate (RF). What gets built below is therefore a portfolio of twenty trading strategies. Twenty of them, the Fama-French five plus momentum, reversals and a dozen anomaly deciles, several deliberately redundant (four value proxies, three low-risk proxies). Annualized premia on the left; the correlation heatmap and PCA scree on the right show the themes clumping and a handful of components explaining most of the covariation — the redundancy a sparse selector exploits.

In [2]:
prem<-colMeans(R)*12; o<-order(prem)
options(repr.plot.width=13, repr.plot.height=4.4); par(mfrow=c(1,2), mar=c(7,4,3,1))
barplot(prem[o], las=2, col=ifelse(prem[o]>0,"#2f855a","#c53030"), ylab="annualized premium (%)", main="The factor zoo (1963-2026)", cex.names=0.7)
ev<-eigen(cor(R))$values; cum<-cumsum(ev)/sum(ev); k90<-which(cum>=0.9)[1]
par(mar=c(4,4,3,4)); barplot(ev, col="#a0aec0", xlab="principal component", ylab="eigenvalue", main="PCA scree -- few independent dimensions")
par(new=TRUE); plot(seq_along(cum), cum*100, type="b", col="#2b6cb0", lwd=2, axes=FALSE, xlab="", ylab=""); axis(4); mtext("cumulative variance (%)", 4, 3, col="#2b6cb0")
abline(h=90, lty=3, col="#c53030"); text(N*0.5, 82, sprintf("%d PCs = 90%%", k90), col="#c53030")
par(mfrow=c(1,1))
set.seed(0)
knull<-mean(replicate(200,{e<-eigen(cor(matrix(rnorm(nrow(R)*N),nrow(R),N)))$values; which(cumsum(e)/sum(e)>=0.9)[1]}))
cat(sprintf("%d principal components are needed for 90%% of the covariation among %d factors, against %.0f for the same\n", k90, N, knull))
cat(sprintf("number of INDEPENDENT series of this length. Real redundancy -- but PC1 alone is only %.0f%%, so this is not\n", 100*cum[1]))
cat("the 'the zoo is really one factor' story: enough duplication for a selector to exploit, not enough to call it a facade.\n")
10 principal components are needed for 90% of the covariation among 20 factors, against 18 for the same
number of INDEPENDENT series of this length. Real redundancy -- but PC1 alone is only 32%, so this is not
the 'the zoo is really one factor' story: enough duplication for a selector to exploit, not enough to call it a facade.
No description has been provided for this image

2. The in-sample mirage¶

Form the tangency SDF by regressing ones on the standardized factor returns (no intercept) in-sample on 1963-2000. In-sample it posts a spectacular Sharpe — but that is $\Sigma^{-1}\mu$ fitting estimation noise across correlated factors. Held out on 2001-2026 the same weights deliver a fraction of it. This is exactly the estimation-risk problem of Bayesian Estimation & Estimation Risk, met there with a prior and here with a penalty. 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$, annualized by multiplying the monthly figure by $\sqrt{12}$. It asks how much return you are paid per unit of risk, and it is the natural yardstick for a tangency portfolio because the tangency portfolio is defined as the one that maximizes it. For scale: a broad equity index earns about 0.4–0.5 over long samples, a good active strategy sustains something near 1, and a backtest reporting 2+ is usually a diagnosis rather than an achievement. It is also invariant to leverage, which is what lets it compare a 20-factor portfolio with a single-factor one on equal terms.

In [3]:
tr<-p$ym<=200012; Rtr<-R[tr,]; Rte<-R[!tr,]; sdv<-apply(Rtr,2,sd)
Ztr<-scale(Rtr,center=FALSE,scale=sdv); Zte<-scale(Rte,center=FALSE,scale=sdv); y<-rep(1,nrow(Ztr))
bols<-as.numeric(solve(t(Ztr)%*%Ztr, t(Ztr)%*%y))          # OLS tangency (Britten-Jones)
is_s<-sharpe(Ztr%*%bols); oos_s<-sharpe(Zte%*%bols)
cat(sprintf("Kitchen-sink tangency SDF (%d factors):  IN-SAMPLE Sharpe %.2f (mirage)  ->  OOS Sharpe %.2f (reality)\n", N, is_s, oos_s))
options(repr.plot.width=6, repr.plot.height=4.2); par(mar=c(4,4,3,1))
bp<-barplot(c(is_s,oos_s), names.arg=c("in-sample\n(1963-2000)","out-of-sample\n(2001-2026)"), col=c("#a0aec0","#c53030"),
            ylab="annualized Sharpe", main="Kitchen-sink SDF: mirage vs reality")
text(bp, c(is_s,oos_s)+0.05, sprintf("%.2f",c(is_s,oos_s)))
cat("A Sharpe far above any single factor in-sample is not skill -- it is overfitting the sample covariance.\n")
Kitchen-sink tangency SDF (20 factors):  IN-SAMPLE Sharpe 2.41 (mirage)  ->  OOS Sharpe 0.40 (reality)
A Sharpe far above any single factor in-sample is not skill -- it is overfitting the sample covariance.
No description has been provided for this image

3. Lasso selection — taming the zoo¶

glmnet traces the Lasso path from the 20-factor kitchen sink down to one factor. In-sample Sharpe climbs with every added factor; out-of-sample Sharpe rises to a peak around a dozen factors and then falls back — an inverted U rather than an early plateau. The cross-validated sparse SDF (one-standard-error rule) keeps about half the factors and slightly beats the kitchen sink out of sample, naming the survivors and pruning the redundant value and low-risk proxies. How sparse it is turns out to depend on the cross-validation rule, which the cell measures.

In [4]:
fit<-glmnet(Ztr,y,alpha=1,intercept=FALSE,standardize=FALSE)
B<-as.matrix(fit$beta); nf<-fit$df
is_path<-apply(B,2,function(b) if(sum(abs(b))>0) sharpe(Ztr%*%b) else 0)
oos_path<-apply(B,2,function(b) if(sum(abs(b))>0) sharpe(Zte%*%b) else 0)
# lambda.1se, not lambda.min. cv.glmnet is randomised, so seed it and check: across seeds 1-6 lambda.min
# selects 15-19 of 20 factors -- it prunes essentially nothing and keeps ALL five value proxies -- while
# lambda.1se stably keeps ~10 and scores better out of sample. The one-standard-error rule is the usual
# parsimony choice and is also what reproduces the Python notebook's LassoCV selection.
set.seed(1)
cv<-cv.glmnet(Ztr,y,alpha=1,intercept=FALSE,standardize=FALSE,nfolds=5)
bl<-as.numeric(coef(cv,s="lambda.1se"))[-1]; kl<-sum(abs(bl)>1e-8)
bmin<-as.numeric(coef(cv,s="lambda.min"))[-1]; kmin<-sum(abs(bmin)>1e-8)
sel<-sapply(1:6,function(s){set.seed(s); cvv<-cv.glmnet(Ztr,y,alpha=1,intercept=FALSE,standardize=FALSE,nfolds=5)
  c(min=sum(abs(as.numeric(coef(cvv,s="lambda.min"))[-1])>1e-8), se1=sum(abs(as.numeric(coef(cvv,s="lambda.1se"))[-1])>1e-8))})
options(repr.plot.width=13.5, repr.plot.height=4.8); par(mfrow=c(1,2), mar=c(4,4,3,1))
plot(nf, is_path, type="b", pch=19, col="#a0aec0", lwd=2, ylim=range(c(is_path,oos_path)), xlab="number of factors in SDF", ylab="annualized Sharpe", main="Overfitting scissors: fit rises, OOS plateaus")
lines(nf, oos_path, type="b", pch=19, col="#2b6cb0", lwd=2); abline(v=kl, lty=2, col="#2f855a")
legend("bottomright", c("in-sample","out-of-sample",sprintf("Lasso-CV: %d factors",kl)), col=c("#a0aec0","#2b6cb0","#2f855a"), lty=c(1,1,2), pch=c(19,19,NA), lwd=2, bty="n", cex=0.8)
srt<-order(abs(bl)); srt<-srt[abs(bl[srt])>1e-8]
par(mar=c(4,7,3,1)); barplot(bl[srt], names.arg=fac[srt], horiz=TRUE, las=1, col=ifelse(bl[srt]>0,"#2f855a","#c53030"), xlab="SDF weight (standardized)", main=sprintf("Sparse SDF: %d factors Lasso keeps",kl))
par(mfrow=c(1,1))
VAL<-c("HML","Value_BEME","Earn_EP","Cash_CFP","Div_DP"); LOW<-c("Var","ResVar","Beta")
kept<-rev(fac[srt])
cat(sprintf("Lasso-CV SDF (lambda.1se): %d of %d factors, OOS Sharpe %.2f against the kitchen sink's %.2f -- it edges the\n", kl, N, sharpe(Zte%*%bl), oos_s))
cat(sprintf("20-factor fit rather than merely matching it, on %d of the 20 inputs.\n", kl))
cat(sprintf("Kept: %s\n", paste(kept, collapse=", ")))
cat(sprintf("Pruning falls where the duplication is: %d of %d value proxies survive (%s) and %d of %d low-risk proxies (%s).\n",
    sum(kept%in%VAL), length(VAL), paste(kept[kept%in%VAL],collapse=", "),
    sum(kept%in%LOW), length(LOW), paste(kept[kept%in%LOW],collapse=", ")))
cat(sprintf("The CHOICE OF RULE matters more than the choice of language here: at lambda.min this same fit keeps %d of %d\n", kmin, N))
cat(sprintf("factors and prunes nothing, and across seeds 1-6 lambda.min ranges %d-%d while lambda.1se holds at %d-%d.\n",
    min(sel["min",]), max(sel["min",]), min(sel["se1",]), max(sel["se1",])))
cat("A paper reporting 'the Lasso selects a sparse SDF' is reporting its cross-validation rule as much as its data.\n")
Lasso-CV SDF (lambda.1se): 12 of 20 factors, OOS Sharpe 0.45 against the kitchen sink's 0.40 -- it edges the
20-factor fit rather than merely matching it, on 12 of the 20 inputs.
Kept: ResVar, Mom, SMB, ST_Rev, Mkt.RF, LT_Rev, RMW, CMA, Accr_AC, HML, Beta, Div_DP
Pruning falls where the duplication is: 2 of 5 value proxies survive (HML, Div_DP) and 2 of 3 low-risk proxies (ResVar, Beta).
The CHOICE OF RULE matters more than the choice of language here: at lambda.min this same fit keeps 19 of 20
factors and prunes nothing, and across seeds 1-6 lambda.min ranges 15-19 while lambda.1se holds at 10-12.
A paper reporting 'the Lasso selects a sparse SDF' is reporting its cross-validation rule as much as its data.
No description has been provided for this image

4. Out-of-sample, and summary¶

Cumulative out-of-sample return of the sparse Lasso SDF vs the kitchen-sink and equal-weight (sign-corrected).

In [5]:
nrm<-function(b){ s<-sum(abs(b)); if(s>0) b/s else b }
w_ks<-nrm(bols); w_l<-nrm(bl); w_eq<-nrm(sign(colMeans(Rtr)))
ret<-list("kitchen-sink OLS"=Zte%*%w_ks,"Lasso sparse SDF"=Zte%*%w_l,"equal-weight (signs)"=Zte%*%w_eq)
dte<-as.Date(paste0(p$ym[!tr]%/%100,"-",p$ym[!tr]%%100,"-01"))
options(repr.plot.width=9, repr.plot.height=4.4); par(mar=c(4,4,3,1)); cols<-c("#a0aec0","#2b6cb0","#dd6b20")
plot(dte, cumsum(ret[[1]]), type="l", col=cols[1], lwd=2, ylim=range(sapply(ret,cumsum)), xlab="", ylab="cumulative OOS return (%)", main="Out-of-sample SDF performance (2001-2026)")
for(i in 2:3) lines(dte, cumsum(ret[[i]]), col=cols[i], lwd=2)
legend("topleft", sprintf("%s (Sharpe %.2f)", names(ret), sapply(ret,sharpe)), col=cols, lwd=2, bty="n", cex=0.85)
cat("Verdict: the sparse Lasso SDF matches the kitchen sink OOS with half the factors -- parsimony and interpretability\n")
cat("at no cost. It does NOT dramatically beat OLS on Sharpe: with 60 years of 20 diversified factors the sample tangency\n")
cat("is already decent. Shrinkage's OOS edge grows with dimension (Kozak-Nagel-Santosh: hundreds of portfolios).\n")
Verdict: the sparse Lasso SDF matches the kitchen sink OOS with half the factors -- parsimony and interpretability
at no cost. It does NOT dramatically beat OLS on Sharpe: with 60 years of 20 diversified factors the sample tangency
is already decent. Shrinkage's OOS edge grows with dimension (Kozak-Nagel-Santosh: hundreds of portfolios).
No description has been provided for this image

Summary¶

glmnet reproduces the Python result: the factor zoo is redundant (few PCs explain most covariation); the in-sample tangency is an overfitting mirage that collapses out of sample; and the Lasso selects a sparse, interpretable SDF — roughly half the factors, pruning the redundant value and low-risk proxies — that matches the kitchen sink out of sample. The honest verdict is unchanged: with 20 diversified factors over 60 years, shrinkage does not beat OLS on Sharpe (its edge scales with dimensionality); its value is parsimony and stability. Cross-links: the SDF/tangency portfolio is the mean-variance object of Shrinkage Estimation of Mean and Covariance, Bayesian Estimation & Estimation Risk and The Black–Litterman Model, where the same $\Sigma^{-1}\mu$ instability is handled with a prior instead of a penalty; and Lasso = a Laplace prior on the SDF weights, the frequentist edge of the Variable Selection arc. Package mirror of factor_selection_python.ipynb.