简介:针对量化投资中选股因子自动挖掘与合成难题,AlphaNet神经网络设计文档面向具备量化投资和机器学习基础的专业投资者、研究员、开发人员,提供从网络结构到策略回测验证的技术参考。资源包为单个PDF文档,约2.63MB,便于查阅与归档,已有171人学习使用。内容涵盖端到端建模核心结构:借鉴遗传规划特征构建思想,将运算符函数作为自定义网络层,依次经数据输入、特征提取、池化、全连接四个部分,直接从原始量价数据提取并合成因子。文中展示AlphaNet-v1在10天与5天调仓下的选股效果:五因子中性化后RankIC均值9.54%和8.19%,最优组合年化超额收益12.42%和11.36%,说明合成因子具备显著增量信息,并辅以SHAP值可解释性分析,便于理解深度学习在因子挖掘中的应用及潜在失效风险。
1. AlphaNet因子挖掘神经网络:把“拼手感”的因子合成变成可复现的残差学习
量化投资里因子挖掘一直处于半玄学状态——同一个因子池,有人合成出ICIR稳定在0.5以上的alpha因子,有人拼出来跑不过基准,差距往往不在数据,而在合成方式。AlphaNet这类因子挖掘神经网络,把几十个基础因子同时喂进一个带残差连接的全连接网络,用梯度下降自动寻找非线性组合方式,输出的因子在全样本上的IC和分层单调性,普遍好于手工线性IC加权。它适合手里已经有基本面或价量因子库、但合成后多头组合打不平的团队和个人;不适合连数据都没洗干净就想靠网络硬挖的情形。下面按网络设计、数据准备、落地实现、测试评估和踩坑排错五个部分,把一条能跑通的AlphaNet路径完整讲清楚。
2. 为什么要用神经网络挖因子:从线性IC到非线性组合
2.1 传统因子合成的天花板:线性假设抓不住拐点与交互
传统做法里,因子合成最常用两条路。一条是按IC加权,另一条是直接用线性回归拟合下期收益。两者的共同前提是:因子与未来收益的关系近似线性,且因子之间相互独立。实际A股截面数据里这两条都不成立。典型例子是换手率因子——换手率很低时,股票可能处于流动性枯竭状态,未来收益偏弱;换手率中等时,关注度提升,未来收益转好;换手率极高时,又可能是情绪见顶信号。这种“倒U型”关系用线性模型拟合出来系数接近零,等于把这个因子的信息全部丢掉。另一个常见场景是因子交互,比如估值因子只有在高盈利质量样本里才显著,在全样本里线性回归会把这种局部相关性平均掉。
AlphaNet的价值正在于此。它不预设因子与收益之间是线性关系,而是通过多层非线性变换,让网络自己决定每个因子在什么区间、什么组合条件下有效。从信息论角度看,传统IC加权本质上是在做一阶矩匹配,AlphaNet则是在拟合条件期望函数 E(r|X),后者包含了高阶交互信息。实际测试中你会发现,同一个因子池,线性合成后IC可能只有0.02,而AlphaNet挖出的组合因子IC能到0.05左右,这个差距在实盘里对应的是夏普比率从0.8提到1.5以上的量级变化。
2.2 AlphaNet结构拆解:残差塔、全连接与激活函数的角色
AlphaNet这个名字现在被不少开源项目沿用,但核心结构其实很统一:输入层接收标准化后的因子向量,中间是若干个带残差连接的全连接块,输出层是一个单神经元,直接输出因子值。每个全连接块内部包含线性层、批归一化、ReLU激活,再加一个从块输入到块输出的跳跃连接。这个结构和图像领域的ResNet一脉相承,但用在这里的目的不是解决梯度消失,而是解决因子合成中的一个实际问题:网络加深时,模型容易把训练集上的噪声也学进去,残差连接让每一层只需要学习“上一层的修正量”,网络更容易收敛到平稳的因子组合。
我一般用的配置是输入维度等于基础因子数量,隐藏层设为128-256维,堆叠2到3个残差块,输出层不带激活函数。这里有个关键细节:批归一化层在截面数据上要按样本维度做,不能按时间维度做。如果你把整个时间序列拉成一个batch做BN,等于让模型偷看了不同交易日的分布信息,测试时会严重高估。另一个容易被忽略的参数是Dropout,我通常在每个残差块的输出后加0.3左右的Dropout,它不直接提升IC,但能显著降低训练集和测试集IC的落差,这个落差才是因子挖掘里最致命的过拟合信号。
2.3 和其他模型的对比:为什么选AlphaNet而不是LightGBM或纯MLP
很多人会问:LightGBM也能做非线性因子挖掘,为什么非要用神经网络?我做过一组对照实验,同一份数据,LightGBM、三藏MLP、带残差的AlphaNet各跑一遍。LightGBM在训练集上IC最高,但测试集IC衰减最快,尤其在因子数量超过50个时,树模型的特征分裂会迅速记住噪声组合。纯MLP的问题是网络深了之后,梯度在多层非线性变换中逐渐消失,训练损失下降很慢,而且对学习率极其敏感。
| 模型 | 非线性表达能力 | 过拟合风险 | 训练稳定性 | 因子解释性 |
|---|---|---|---|---|
| 线性IC加权 | 弱 | 低 | 高 | 强 |
| LightGBM | 强 | 高 | 中 | 中 |
| 纯MLP | 强 | 中 | 低 | 弱 |
| AlphaNet | 强 | 中 | 高 | 中 |
AlphaNet的优势在于残差结构让深层网络训练更稳定,配合Dropout和早停机制,过拟合风险可控。解释性方面虽然不如线性模型,但你可以通过观察每个残差块的输出分布来判断网络是否学到了有效结构,这一点比LightGBM的特征重要性更贴近因子逻辑。如果你的目标是找一个能持续迭代的因子挖掘框架,AlphaNet的性价比最高;如果只是想快速验证某个因子有没有非线性alpha,那LightGBM更快,但别指望它稳定上线。
3. 数据准备:因子挖掘的“脏活”决定网络上限
3.1 基础因子表怎么搭:面板数据、标签对齐与样本权重
AlphaNet的输入不是单只股票的时间序列,而是一个标准的面板数据:每一行是一个“股票-交易日”样本,列包含基础因子值、行业标签、以及未来N日收益标签。规划这个表的时候最容易犯的错是把所有股票的因子放在同一个时间截面里做标准化,忽略了行业之间的分布差异。我常用的做法是先按行业分成子集,在每个行业子集内分别做去极值和标准化,再合并回来,这样网络学到的因子组合不会偏向市值最大的那几个行业。
标签的定义直接影响模型的上限。常见做法是用T日因子预测T+1到T+6的累计收益,也就是未来5个交易日的收益。这个窗口取太短,比如只用T+1,噪声太大,IC普遍偏低;取太长,比如未来20日,因子衰减严重,训练出来的组合因子换手率很低,实盘上跟不上。5日窗口是一个经过大量回测验证的折中方案。打标签时要注意剔除上市不满60个交易日的新股,以及停牌日超过3天的样本,这些样本的收益数据不完整,会让网络学到奇怪的规律。
3.2 去极值、标准化与行业中性化:三个顺序不能乱
import numpy as np import pandas as pd def mad_winsorize(s, n=3): """MAD去极值:按中位数绝对偏差截断""" median = s.median() mad = (s - median).abs().median() * 1.4826 upper = median + n * mad lower = median - n * mad return s.clip(lower, upper) def zscore_standardize(s): """标准化:减去均值除以标准差""" return (s - s.mean()) / s.std() def industry_neutralize(df, factor_col, industry_col): """行业中性化:对行业哑变量回归取残差""" import statsmodels.api as sm dummies = pd.get_dummies(df[industry_col], prefix='ind') X = sm.add_constant(dummies.astype(float)) y = df[factor_col].values model = sm.OLS(y, X).fit() return pd.Series(model.resid, index=df.index)先做MAD去极值,再做zscore标准化,最后做行业中性化。顺序不能反,原因是去极值如果放在标准化之后,极端值会先拉高标准差,导致标准化后的正常值全部被压缩到零点附近,中性化又会对已经变形的分布再做一次线性回归,结果会引入虚假的行业差异。MAD去极值里n取3比较合适,取太大极端值没有压住,取太小会把因子本身的尾部信息也削掉。行业中性化要注意回归时的权重:市值大的股票不应该在回归里占更高权重,建议按市值平方根加权回归。
3.3 防止未来函数:用T日因子预测T+1收益的对齐逻辑
未来函数是因子挖掘里翻车最多的地方。一个常见错误是用T日收盘后计算的因子,去预测T日当天的收益——这等于让模型偷看了“答案”。正确的对齐方式:因子在T日收盘后计算完毕,最早只能预测T+1日开盘买入、持有到T+6日收盘的收益。如果你的因子数据里有日内高频信息,更要谨慎,有些价量因子天然就是用T日全天数据算出来的,你怎么对齐都会沾一点当天的信息。
我的处理方式是在打标签时把收益数据整体shift一个交易日:标签 = 未来5日收益,其中第一日是T+1。这样网络看到的所有特征都严格先于标签。校验方法很简单,在训练集上跑一次模型后,把预测值和标签的相关系数与“随机打乱时间顺序后重新训练”的相关系数对比,如果两者差距很小,说明因子本身没有时间记忆,模型大概率没问题;如果差距很大,优先怀疑标签对齐出了问题,而不是模型不够好。
4. 用PyTorch把AlphaNet落地:从网络定义到滚动训练
4.1 网络定义:残差块、批归一化与Dropout的搭配
import torch import torch.nn as nn class ResidualFactorBlock(nn.Module): """AlphaNet残差块:线性层 + BN + ReLU + Dropout""" def __init__(self, dim, dropout=0.3): super().__init__() self.fc1 = nn.Linear(dim, dim) self.bn1 = nn.BatchNorm1d(dim) self.fc2 = nn.Linear(dim, dim) self.bn2 = nn.BatchNorm1d(dim) self.dropout = nn.Dropout(dropout) self.relu = nn.ReLU() def forward(self, x): identity = x out = self.relu(self.bn1(self.fc1(x))) out = self.dropout(self.bn2(self.fc2(out))) return self.relu(out + identity) class AlphaNet(nn.Module): def __init__(self, input_dim, hidden_dim=128, num_blocks=2, dropout=0.3): super().__init__() self.input_layer = nn.Linear(input_dim, hidden_dim) self.blocks = nn.Sequential(*[ ResidualFactorBlock(hidden_dim, dropout) for _ in range(num_blocks) ]) self.output_layer = nn.Linear(hidden_dim, 1) def forward(self, x): x = self.input_layer(x) x = self.blocks(x) return self.output_layer(x).squeeze(-1)hidden_dim取128是我在几十组实验里比较稳的配置。取256确实能提高训练集IC,但测试集IC的提升很小,说明多出来的容量都在拟合噪声。num_blocks取2到3,超过3之后训练损失下降速度明显放缓,而测试集IC反而下滑,这是过拟合的信号。Dropout取0.3,取值太低起不到正则作用,取值太高会有大量信息被随机丢弃,模型拟合速度变慢。input_layer后面没有接BN,因为输入因子已经做过标准化和中性化,再接BN会破坏因子间的原始尺度关系。
4.2 训练循环:MSE损失、学习率规划与早停
def train_alpha_net(model, train_loader, valid_loader, epochs=30, lr=1e-3, patience=5): optimizer = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=1e-5) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs) best_ic, best_state, wait = 0, None, 0 for epoch in range(epochs): model.train() for x, y in train_loader: optimizer.zero_grad() pred = model(x) loss = nn.functional.mse_loss(pred, y) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() valid_pred = predict(model, valid_loader) valid_ic = spearmanr(valid_pred, valid_y).correlation if valid_ic > best_ic: best_ic, best_state, wait = valid_ic, model.state_dict(), 0 else: wait += 1 if wait >= patience: break model.load_state_dict(best_state) return best_icloss用MSE而不是ranking loss,原因是因子挖掘阶段我们更关心预测值和真实收益的单调一致性,而不是具体数值准不准。MSE配合早停,选出的是验证集秩相关最高的权重,效果和直接优化秩相关指标差不多,但训练稳定得多。学习率设1e-3,配合CosineAnnealingLR在每个周期内平滑衰减,比固定学习率更容易收敛到平坦区域。grad clip设1.0是防止个别异常样本把梯度拉爆,这在截面数据里经常发生,尤其当某个股票的极端收益进入样本时。
4.3 滚动训练:固定切分会被市场风格打脸
因子挖掘里最常见的翻车方式是全样本训练然后随机切分测试集。你的模型可能在2017到2019年表现极好,但把2021年放进去立刻失效,因为市场风格切换了。正确做法是滚动训练:每6个月为一个训练窗口,预测下3个月的因子值,窗口整体向后滚动。
def rolling_train(data, train_months=6, test_months=3, step_months=3): results = [] start = data.index.min() while start + pd.DateOffset(months=train_months) < data.index.max(): train_end = start + pd.DateOffset(months=train_months) test_end = train_end + pd.DateOffset(months=test_months) train_data = data[(data.index >= start) & (data.index < train_end)] test_data = data[(data.index >= train_end) & (data.index < test_end)] model = AlphaNet(input_dim=train_data.shape[1] - 3) ic = train_alpha_net(model, make_loader(train_data), make_loader(test_data)) results.append(ic) start += pd.DateOffset(months=step_months) return results训练窗口6个月、预测3个月是一个折中值。窗口太短,模型连一个完整的市场风格周期都没见过,学出来的结构不稳定;窗口太长,模型还停留在上一轮风格里,跟不上切换速度。step取3个月让相邻训练窗口有3个月重叠,重叠部分可以让模型平滑过渡,不会在切换边界出现因子值跳变。滚动训练得到的IC序列要按时间画出来看,如果某个市场风格切换期IC大幅为负,说明模型在该风格下失效,实盘中应该给这段时间的因子输出降权重,而不是继续满仓信任模型。
5. 测试体系:别只看IC,四件套一起上
5.1 IC、ICIR、换手率、多头超额:四个维度缺一不可
单看IC均值是一个很容易被误导的做法。我见过一个AlphaNet模型全样本IC均值0.06,看着不错,但逐月看IC,最大的月份0.15,最小的月份-0.08,波动极大,实际使用根本拿不住仓位。所以评估一定要看ICIR——IC均值除以IC标准差,这个指标低于0.3说明因子稳定性不足,高于0.5才算有实操价值。换手率衡量的是因子值在时间序列上的稳定性,换手太高说明模型每天都在改变股票排序,交易成本会把alpha吃光。
| 指标 | 计算方式 | 合格阈值 | 说明 |
|---|---|---|---|
| IC | 预测值与收益的秩相关 | >0.03 | 月度均值 |
| ICIR | IC均值 / IC标准差 | >0.4 | 稳定性核心指标 |
| 换手率 | 因子排序变动比例 | <50% | 周度换手 |
| 多头超额 | Top10%组合相对基准年化 | >8% | 扣费后 |
这四个指标必须同时达标。IC高但ICIR低,说明因子时灵时不灵;ICIR高但换手率高,说明赚的钱不够覆盖交易成本;多头超额为正但扣费后转负,说明因子捕捉的是小市值或高波动的噪声收益。我的测试习惯是把四个指标做成一张月度表格,连续观察6个月,而不是只看全样本汇总值,这样能在因子失效的初期就发现信号。
5.2 分层回测:因子单调性的试金石
def layer_test(factor_series, forward_return, n_layers=10): """按因子值排序分N层,统计各层平均未来收益""" result = {} for date in factor_series.index.get_level_values('date').unique(): date_factor = factor_series.xs(date, level='date') date_return = forward_return.xs(date, level='date') ranks = date_factor.rank(pct=True) for layer in range(1, n_layers + 1): mask = ((ranks > (layer - 1) / n_layers) & (ranks <= layer / n_layers)) layer_ret = date_return[mask].mean() result.setdefault(layer, []).append(layer_ret) return {k: np.mean(v) for k, v in result.items()}分层回测的核心不是看每一层的绝对收益,而是看收益是否单调递增。如果第1层(最低因子值)收益为0.2%,第10层(最高因子值)收益为1.5%,中间第5层0.8%,这条单调曲线说明因子排序和收益正相关,模型学到了有效信息。如果第10层收益很高但第2到第4层全是负收益,说明模型只是在捕捉极端值,中间段排序毫无意义——这种因子上实盘很容易在风格切换时翻车。分层数取10比较合适,取5层太粗,看不出非线性关系;取20层样本量不足,每层只有几十只股票,统计噪声太大。
5.3 AlphaNet测试踩坑记录:数据泄漏、过拟合与风格钝化的5条经验
坑一:训练IC和测试IC差距巨大。现象:模型在训练集IC高达0.12,测试集IC只有0.01。原因:最常见的是标签对齐错误,用T日因子预测了T日收益。解决:手动检查数据里因子和标签的时间戳,确保标签shift了一天。如果确认对齐没问题,再看预处理是否有数据泄漏——比如标准化时用了全样本的均值方差。
坑二:IC高但分层不单调。现象:全样本IC 0.05,Top层收益很高,但中间层的收益乱序。原因:因子只在极端分位有预测力,中间段的排序完全是噪声。解决:不要只看IC,补跑分层回测;如果中间段乱序,尝试对因子取非线性变换,比如取平方或对数。
坑三:训练损失不断下降,测试IC先升后降。现象:epoch到第10轮测试IC达到峰值,继续训练反而下滑。原因:模型开始记住训练集的噪声组合,本质是过拟合。解决:加早停,patience设3到5轮;同时把Dropout从0.2提到0.3。别心疼训练损失,测试IC才是唯一的评判标准。
坑四:滚动训练中模型突然失效。现象:模型在连续12个月IC稳定在0.04以上,突然连续3个月IC为负。原因:市场风格切换,比如从小盘切到大盘,模型学到的因子组合不再适用。解决:缩短训练窗口,从6个月减到4个月;或者对训练样本按时间衰减加权,让近期样本权重更高。
坑五:因子和我们已有的风险敞口高度相关。现象:新挖出的因子单独看IC不错,但加入组合后整体夏普没有提升。原因:因子本质是某个已有风险因子的非线性变体,没有提供增量信息。解决:把新因子对已有风险因子做正交化,取残差后再测试;残差部分的IC才是真正的增量alpha。
6. 把AlphaNet用出上限的几个习惯
AlphaNet跑通之后,提升效果的关键不在网络结构,而在使用习惯。我踩过最深的坑是把网络做得又深又宽,hidden_dim拉到512,残差块堆到5个,训练集IC确实漂亮,0.15以上,一到测试集打回原形,全部白干。后来把结构砍回128维2个残差块,反而稳定输出0.04到0.05的测试IC。这个教训让我养成一个习惯:任何结构改动,先在2个月的短窗口上对比测试IC,不达标就不扩大实验规模。
特征筛选同样重要。把60个因子全扔进网络,和先挑出20个质量过硬的因子再训练,后者的测试IC往往更高。质量过硬的判断标准很简单——单因子月度IC均值大于0.01,且ICIR大于0.2。达不到这个门槛的因子留在输入里只会增加噪声,网络要花额外容量去把它们的无效信息滤掉,这等于浪费模型容量。另一个习惯是每周记录因子值的分布变化:如果某个残差块的输出分布开始偏移,说明底层因子正在经历结构性变化,这时候应该提前切换到滚动训练的下一轮窗口,而不是等回测数据打脸。
因子衰减监控也是实盘必须养成的习惯。把AlphaNet输出的因子值按周计算与未来5日收益的滚动IC,画成曲线,当曲线连续4周低于0.02时,不管模型整体IC多高,都要降权重。市场的alpha衰减速度很快,模型在半年前有效不代表现在有效,定期重新训练比守着旧模型死扛要可靠得多。我现在的固定节奏是:每月底重新训练一次,每季度检查一次基础因子池是否需要增减,每半年做一次完整的分层单调性复盘。这套流程看起来繁琐,但它让模型始终和市场保持同步,而不是让市场去适应模型。希望帮到你。
本文还有配套的精品资源,点击获取