Paper: Gu, Kelly, Xiu (2020) "Empirical Asset Pricing via Machine Learning", Review of Financial Studies
Experiment: MSE_ind_1yr — MSE-based early stopping, with industry dummies, 1-year validation window
Test Period: 2001-2019 (19 years of out-of-sample evaluation)
Date: 2026-04-10
Raw Data (parquet/xlsx)
|
v
build_long_panel() One row per (month, stock): signals, macro, excess returns
|
v
FeatureScaler.fit() Compute mean/std from training set only
|
v
FeatureScaler.transform() Standardize -> impute NaN to 0 -> clip +/-5 std -> compute interactions
|
v
build_industry_dummies() Append 74 SIC-2 one-hot columns
|
v
GPUData (to CUDA) Transfer to GPU, free CPU arrays
|
v
train_model_exp() x10 Train 10 seeds, MSE early stopping
|
v
Ensemble (mean of 10) Average 10 seed predictions
|
v
compute_oos_metrics() OOS R², IC, long-short Sharpe
| File | Description |
|---|---|
gkx_full/signal_*.parquet |
95 stock-level characteristics, wide format (months x stocks) |
gkx_full/returns.parquet |
Monthly stock returns, wide format |
gkx_full/universe.parquet |
Boolean mask for stock universe membership |
gkx_full/sector_mapping.csv |
PERMNO -> SIC 2-digit industry code mapping |
welch_goyal_2024.xlsx |
Welch-Goyal macroeconomic predictors (Monthly sheet) |
absacc acc aeavol age agr baspread beta betasq
bm bm_ia cash cashdebt cashpr cfp cfp_ia chatoia
chcsho chempia chinv chmom chpmia chtx cinvest convind
currat depr divi divo dolvol dy ear egr
ep gma grcapx grltnoa herf hire idiovol ill
indmom invest lev lgr maxret mom12m mom1m mom36m
mom6m ms mve0 mve_ia mvel1 nincr operprof orgcap
pchcapx_ia pchcurrat pchdepr pchgm_pchsale pchquick
pchsale_pchinvt pchsale_pchrect pchsale_pchxsga pchsaleinv
pctacc pricedelay ps quick rd rd_mve rd_sale
realestate retvol roaq roavol roeq roic rsup
salecash saleinv salerec secured securedind sgr sin
sp std_dolvol std_turn stdacc stdcf tang tb
turn zerotrade
Derived from Welch-Goyal dataset:
| Variable | Formula |
|---|---|
dp |
log(D12) - log(Index) |
ep |
log(E12) - log(Index) |
bm |
b/m (book-to-market, as-is) |
ntis |
Net equity issuance (as-is) |
tbl |
Treasury bill rate (as-is) |
tms |
lty - tbl (term spread) |
dfy |
BAA - AAA (default yield spread) |
svar |
Stock variance (as-is) |
Excess return: raw monthly stock return minus the risk-free rate (Rfree derived from tbl in Welch-Goyal).
Signal alignment: characteristics at month t predict excess return at month t+1.
74 one-hot columns from SIC 2-digit codes. PERMNOs are mapped via sector_mapping.csv. Stocks with unknown SIC get all-zero dummy rows.
| Component | Count | Description |
|---|---|---|
| Stock signals | 95 | Raw characteristics |
| Macro predictors | 8 | Welch-Goyal derived |
| Interactions | 760 | 95 signals x 8 macro (cross-products) |
| Industry dummies | 74 | SIC 2-digit one-hot |
| Total | 937 |
Applied per refit year. Scaler fitted on training data only.
- Standardize: z-score each feature using training-set
nanmeanandnanstd - Impute: replace remaining NaN with 0 (= training-set mean after standardization)
- Clip: truncate at +/- 5 standard deviations
- Interactions: compute 95 x 8 = 760 cross-products of standardized stock and macro features (done in 200K-row chunks)
NN5: 5 hidden layers, geometric pyramid.
Input (937) -> Linear(937, 32) -> BatchNorm -> ReLU -> Dropout(0.05)
-> Linear(32, 16) -> BatchNorm -> ReLU -> Dropout(0.05)
-> Linear(16, 8) -> BatchNorm -> ReLU -> Dropout(0.05)
-> Linear(8, 4) -> BatchNorm -> ReLU -> Dropout(0.05)
-> Linear(4, 2) -> BatchNorm -> ReLU -> Dropout(0.05)
-> Linear(2, 1) (no activation)
Weight initialization: Kaiming normal (He) for ReLU, zero biases.
Total parameters: ~30,853 (varies slightly with input dimension)
| Parameter | Value |
|---|---|
| Optimizer | Adam |
| Learning rate | 0.001 |
| Beta1, Beta2 | 0.9, 0.999 (PyTorch defaults) |
| Epsilon | 1e-8 |
| Batch size | 10,000 |
| Max epochs | 300 |
| Early stopping metric | Validation MSE (lower = better) |
| Early stopping patience | 25 epochs |
| Min epochs before stopping | 20 |
| Dropout | 0.05 |
| Feature clipping | +/- 5 std |
| Mixed precision | fp16 via torch.amp.autocast('cuda') + GradScaler |
| L1 penalty | None (0.0) |
| Loss function | MSE (mean squared error) |
| Number of seeds | 10 |
| Ensemble method | Simple arithmetic mean of 10 seed predictions |
| CUDA deterministic | True |
| cuDNN benchmark | False |
For each test year Y:
| Split | Period | Description |
|---|---|---|
| Train | 1975-01 to (Y-2)-12 | All data before validation window |
| Validation | (Y-1)-01 to (Y-1)-12 | 1-year rolling window |
| Test | Y-01 to Y-12 | Out-of-sample evaluation |
Example for test_year = 2016:
| Split | Period | Approximate Obs |
|---|---|---|
| Train | 1975-01 to 2014-12 | ~2.9M |
| Val | 2015-01 to 2015-12 | ~67K |
| Test | 2016-01 to 2016-12 | ~68K |
best_val_loss = infinity
patience_counter = 0
for epoch in 1..300:
train one epoch (mini-batch SGD with Adam)
compute val_mse on full validation set
if epoch >= 20: # min_epochs guard
if val_mse < best_val_loss:
best_val_loss = val_mse
save model state
patience_counter = 0
else:
patience_counter += 1
if patience_counter >= 25:
break
return best model state
For each (test_year, architecture):
- Train 10 models with seeds 0-9
- Each seed:
torch.manual_seed(seed),np.random.seed(seed),torch.cuda.manual_seed_all(seed) - Generate test predictions from each seed's best model
- Final prediction = mean of 10 seed predictions
Data splits are processed serially to limit peak CPU RAM:
- Build train features -> transfer to GPU -> free CPU array
- Build val features -> transfer to GPU -> free CPU array
- Build test features -> transfer to GPU -> free CPU array
R² = 1 - SSE / SST
SSE = sum((actual - predicted)^2)
SST = sum(actual^2) # NOT mean-adjusted, per GKX convention
Pooled across all stocks and months within each test year.
Monthly Spearman rank correlation between predicted and actual excess returns. Computed independently for each month, then averaged.
Per month:
- Sort stocks by predicted excess return
- Long = top decile (10%), Short = bottom decile (10%)
- Equal-weighted returns within each decile
- L/S return = mean(long returns) - mean(short returns)
- Annualized Sharpe = (mean monthly L/S) / (std monthly L/S) * sqrt(12)
Minimum 20 stocks per month, minimum 2 stocks per decile.
| Year | OOS R² (%) | Mean IC | L/S %/mo | Sharpe |
|---|---|---|---|---|
| 2001 | -0.120 | +0.002 | +1.09 | +0.82 |
| 2002 | -0.592 | -0.003 | +1.47 | +0.79 |
| 2003 | +1.016 | -0.003 | +0.72 | +0.69 |
| 2004 | +0.217 | -0.021 | +0.62 | +1.02 |
| 2005 | +0.624 | -0.010 | +0.03 | +0.07 |
| 2006 | +0.181 | +0.009 | +1.02 | +1.80 |
| 2007 | -1.537 | +0.008 | +0.47 | +1.41 |
| 2008 | -1.987 | +0.021 | -0.63 | -0.70 |
| 2009 | -0.786 | -0.020 | -0.02 | -0.02 |
| 2010 | +1.416 | -0.029 | +0.01 | +0.02 |
| 2011 | +0.772 | -0.001 | +1.25 | +1.58 |
| 2012 | +0.796 | +0.031 | +0.94 | +1.94 |
| 2013 | +1.772 | +0.052 | +2.19 | +3.96 |
| 2014 | -1.549 | -0.018 | +0.26 | +0.25 |
| 2015 | -0.168 | +0.045 | +1.79 | +1.93 |
| 2016 | -1.486 | +0.026 | +0.60 | +0.44 |
| 2017 | +0.701 | -0.003 | +0.72 | +2.44 |
| 2018 | +0.066 | +0.044 | +1.69 | +2.32 |
| 2019 | -4.021 | -0.018 | -0.30 | -0.21 |
| Metric | Value |
|---|---|
| Average OOS R² | -0.25% |
| Average IC | +0.006 |
| Average L/S Return | +0.73%/mo |
| Average Annualized Sharpe | +1.07 |
| Sharpe Std (across years) | 1.05 |
| Positive Sharpe years | 15 / 19 (79%) |
| Worst year | 2008 (Sharpe = -0.70) |
| Best year | 2013 (Sharpe = +3.96) |
From Internet Appendix Table A.5:
| Parameter | GKX Paper | Ours | Match? |
|---|---|---|---|
| Architecture | NN1-NN5 | NN5 only | Partial |
| Hidden dims (NN5) | (32,16,8,4,2) | (32,16,8,4,2) | Yes |
| Activation | ReLU | ReLU | Yes |
| Batch normalization | Yes | Yes | Yes |
| Dropout | Not mentioned | 0.05 | Unknown |
| Optimizer | Adam | Adam | Yes |
| Learning rate | {0.001, 0.01} grid | 0.001 only | Partial |
| Batch size | 10,000 | 10,000 | Yes |
| Max epochs | 100 | 300 | No |
| Patience | 5 | 25 | No |
| Early stopping metric | Validation MSE | Validation MSE | Yes |
| L1 penalty | lambda in (1e-5, 1e-3) | None | No |
| Ensemble seeds | 10 | 10 | Yes |
| Feature scaling | Rank to [-1,1] per month | StandardScaler + clip +/-5 | No |
| Missing imputation | Monthly cross-sectional median | Training-set mean (0) | No |
| Validation window | 12 years (fixed, rolling) | 1 year | No |
| Training start | 1957 | 1975 | No |
| Industry dummies | 74 (SIC 2-digit) | 74 (SIC 2-digit) | Yes |
| Total input features | 920 | 937 | Close |
- Patience: Paper uses 5, we use 25. Paper stops much more aggressively.
- Max epochs: Paper uses 100, we use 300.
- LR grid: Paper searches {0.001, 0.01}, we fix at 0.001.
- L1 penalty: Paper tunes lambda via validation grid search, we omit it.
- Feature scaling: Paper ranks to [-1,1] per month (robust to outliers), we standardize + clip.
- Validation window: Paper uses 12 years, we use 1 year.
- Training start: Paper starts from 1957, we start from 1975.
Python 3.10+
PyTorch 2.0+ (with CUDA)
numpy, pandas, scipy
GPU: NVIDIA with >= 16GB VRAM (tested on RTX 4080 SUPER)
Place the following in the project root:
gkx_full/
signal_absacc.parquet
signal_acc.parquet
... (95 signal files)
returns.parquet
universe.parquet
sector_mapping.csv
welch_goyal_2024.xlsx
All parquet files are wide format: PeriodIndex (monthly) x stock columns.
To replicate MSE_ind_1yr for 2001-2019:
In train_nn.py, set the experiment config in run_experiments():
experiments = [
{"name": "MSE_ind_1yr", "stop_metric": "mse", "use_dummies": True, "val_years": 1},
]
config = Config(test_years=list(range(2001, 2020)))Then run:
python train_nn.pyoutput/
logs/train_YYYYMMDD_HHMMSS.log # Full training log with per-epoch metrics
models/NN5_year{Y}_seed{S}.pt # Best model checkpoint per seed
predictions/pred_ensemble_NN5_year{Y}.parquet # Ensemble predictions
metrics/experiments_summary.csv # All OOS metrics per year
features/scaler_year{Y}.pkl # Fitted scaler per refit year
- ~2 min per seed (RTX 4080 SUPER with mixed precision)
- 19 years x 10 seeds = 190 models
- Total: ~25 minutes
| Component | File | Lines |
|---|---|---|
| Config | train_nn.py | 30-99 |
| build_long_panel() | train_nn.py | 255-374 |
| FeatureScaler | train_nn.py | 381-447 |
| GKXNet model | train_nn.py | 474-500 |
| compute_oos_metrics() | train_nn.py | 724-782 |
| train_model_exp() | train_nn.py | 1076-1163 |
| run_experiments() | train_nn.py | 1166-1375 |