遗传编程因子挖掘:让表达式树自己进化出可交易 alpha
手工构造因子是『人想公式、数据验证』,遗传编程反过来:把因子表达式编码成树(叶子是特征、内部节点是算子),用交叉互换子树、变异随机替换、锦标赛选择淘汰弱者,让公式在 IC 适应度的驱动下自己进化。本文纯 Python 实现极简 GP 引擎:600×100 合成面板埋入非线性真结构 mom*exp(-|vol|)-0.5*rev,进化 25 代后找到 (mom - rev),验证集 IC 0.098——超过最强单因子(0.078)、逼近真实结构上限(0.105),而且简约压力把树从平均 6.1 节点压到 3.8,没有膨胀成垃圾。但要害在最后一图:500 个随机公式在纯噪声特征上搜索,训练 IC 被『迄今最优』推着单调上涨到 0.018——数据挖掘偏差意味着 GP 天生是过拟合机器,防御靠简约压力+验证集早停+多种子重跑看表达式是否稳定(中阶)。
先说结论:遗传编程(GP)能在你没想到的公式空间里自动搜出可交易因子,但它同时是一台高效的过拟合机器——你给它越多算力,它越擅长把噪声雕刻成好看的训练 IC。本文用纯 Python 从零写一个极简 GP 引擎,在埋了真实非线性结构的合成面板上进化 25 代,最优因子的验证集 IC 达到 0.098,超过最强单因子(0.078)、逼近理论上限(0.105)。然后我会用 500 个随机公式在纯噪声上搜索,向你演示为什么”搜得越多,训练集越好看”是 GP 最危险的性质。
为什么要让公式自己进化#
手工构造因子的流程是:你先有一个金融直觉(“动量好但高波动时不可靠”),翻译成公式(mom * exp(-|vol|)),再拿数据验证。瓶颈在第一步——人的直觉枚举不了公式空间。三个特征、四个算子、深度 3 的表达式树就有上万种组合,其中可能藏着你永远想不到的形态。
遗传编程把这个搜索交给进化:
- 编码:每个候选因子是一棵表达式树。叶子节点是特征(动量、波动率、换手率、反转)或常数,内部节点是算子(加减乘除、取负、绝对值、
exp(-|x|)、符号函数) - 适应度:把树求值成因子值,算训练集上的 Rank IC
- 选择:锦标赛——随机抽 5 个个体,最强者获得繁殖权
- 交叉:从父母各随机剪一棵子树互换,产生新公式
- 变异:随机挑一个节点,替换成一棵随机新子树
- 重复若干代,适应度差的公式被淘汰,好的结构片段在种群里扩散
关键洞察:交叉操作天然保护”有用的子结构”。如果 (mom - rev) 是个好片段,它会作为整体在种群里被复制传播,而不是每次都要从头拼出来。这是 GP 相对随机搜索的本质优势。
极简 GP 引擎:不到 100 行的核心#
表达式树用嵌套元组表示,求值是递归下降:
UNARY = ["neg", "abs", "expn", "sign"] # expn(x) = exp(-|x|)
BINARY = ["add", "sub", "mul", "protdiv"] # protdiv 防除零
TERMS = ["mom", "vol", "turn", "rev", "const"] # 叶子:特征或常数
def rand_tree(depth):
"""随机生成一棵表达式树"""
if depth <= 0 or (depth < 3 and random.random() < 0.3):
t = random.choice(TERMS)
return (t, round(random.uniform(-1, 1), 2)) if t == "const" else (t,)
if random.random() < 0.4:
return (random.choice(UNARY), rand_tree(depth - 1))
return (random.choice(BINARY), rand_tree(depth - 1), rand_tree(depth - 1))
def evaluate(node):
"""把树求值成 (T, N) 的因子值矩阵"""
op = node[0]
if op in FEATS: return FEATS[op] # 特征矩阵
if op == "const": return np.full((T, N), node[1])
if op in UNARY:
x = evaluate(node[1])
if op == "expn": return np.exp(-np.abs(x))
# neg / abs / sign 同理...
a, b = evaluate(node[1]), evaluate(node[2])
if op == "protdiv": return a / (np.abs(b) + 1.0) # 保护性除法
# add / sub / mul 同理...python两个工程细节值得单独说:
- 保护性除法:
a / (|b| + 1)而不是a / b。裸除法会在 b 接近零时产生 inf,一个 inf 污染整个截面排名,让适应度计算崩掉。GP 文献里这叫 protected operator,所有可能产生 NaN/inf 的算子都要包一层。 - 常数叶子:允许树里出现
0.37这种常数,让进化能微调权重(比如mom - 0.5*rev里的 0.5)。没有常数叶子,GP 只能表达等权组合。
交叉和变异都建立在”随机取一个节点、替换整棵子树”之上:
def crossover(a, b):
"""从 a 随机选一个位置,用 b 的随机子树替换"""
pa, _ = random.choice(nodes_list(a)) # a 中随机位置
_, nb = random.choice(nodes_list(b)) # b 中随机子树
return replace(a, pa, nb)
def mutate(a):
p, _ = random.choice(nodes_list(a))
return replace(a, p, rand_tree(2)) # 换成随机新子树python实验设置:埋一个 GP 该找到的真结构#
合成面板 600 天 × 100 只股票,四个标准化特征:动量 mom、波动率 vol、换手率 turn、短期反转 rev。未来收益由一个非线性真实结构驱动:
true_alpha = mom * np.exp(-np.abs(vol)) - 0.5 * rev
fwd_ret = 0.15 * true_alpha + noise # 信噪比故意压低python这个结构的金融语义很自然:动量有效但被高波动打折(exp(-|vol|) 是打折函数),反转贡献负向。前 400 天做训练(适应度只看这里),后 200 天做验证(进化过程完全不接触)。
进化参数:种群 150、代数 25、精英保留 5、锦标赛规模 5、交叉率 0.7、变异率 0.2。适应度是训练集 Rank IC 减去简约压力:
fitness = train_ic - 0.0015 * tree_size # 每多一个节点罚 0.0015python结果:进化找到了 (mom - rev)#
25 代之后,最优个体是 (mom - rev)——只有 3 个节点。看四组数字:

| 因子 | 验证集 Rank IC |
|---|---|
| 动量 mom(最强单因子) | 0.078 |
| 反转 rev | -0.065 |
| 波动率 / 换手率 | ≈0 |
| GP 进化因子 (mom - rev) | 0.098 |
| 真实结构(理论上限) | 0.105 |
三个观察:
- GP 因子比最强单因子高 26%(0.098 vs 0.078)。它发现了”反转的负 IC 可以翻过来用”——
mom - rev等价于做多动量同时做多”反转因子的负向”,把两份信息合成一份。 - 离理论上限只差 7%(0.098 vs 0.105)。没找到
exp(-|vol|)打折项,因为在这个信噪比下,波动率打折带来的边际 IC 增益小于简约压力的惩罚——进化选择了”性价比最高”的结构而不是”最完整”的结构。这不是失败,这正是简约压力在正确工作。 - 训练 IC 0.0984、验证 IC 0.0978,几乎无衰减。简单的表达式泛化好,这是下文所有陷阱讨论的正面参照。

进化曲线显示第 1 代最优就有 0.076(种群 150 里随机撞上了裸 mom),之后逐步爬到 0.098。训练与验证两条线基本贴合——当它们分叉时,就是过拟合的信号,应该早停。
膨胀(bloat):GP 的癌症#
不加约束的 GP 有个臭名昭著的病:表达式树无限膨胀。交叉倾向于产生更大的树(大树里能剪出更多子树),而更大的树里充斥着 (x - x)、sign(sign(x)) 这种中性垃圾——它们不改变适应度,却让公式膨胀到几百个节点,完全不可读、不可信、不可交易。

我们的防御是两层:简约压力(每节点罚 0.0015 IC)+ 硬上限(超过 25 节点直接重新生成)。效果:种群平均节点数从初始 6.1 降到 3.8,最优个体稳定在 3 节点。如果你去掉简约压力重跑,平均尺寸会在 25 代内涨到 20+,而验证 IC 不会更高——多出来的节点全是拟合噪声的自由度。
经验法则:简约系数选多大?让”多 10 个节点”的惩罚大约等于你认为的 IC 噪声水平(本例 0.015)。太小控不住膨胀,太大会把真实的复杂结构也罚没了。
最危险的性质:GP 是数据挖掘偏差的放大器#
现在到全文最重要的部分。GP 的本质是在巨大的公式空间里挑训练集上最好看的那个——这个动作本身就有毒,哪怕特征和收益毫无关系。
实验:4 个纯噪声特征(与收益零相关),随机生成 500 个表达式,每次记录”迄今最优训练 IC”:

红线(迄今最优训练 IC)单调上涨:10 个公式后 0.007,100 个后 0.011,500 个后 0.018。特征是纯噪声,但”最优公式”的训练 IC 看起来越来越像真信号——这就是数据挖掘偏差(data-mining bias / multiple testing):从 K 个随机变量里挑最大值,期望值随 K 单调上升。蓝线(同一公式的验证 IC)在低位震荡,随时变号——噪声不会跨样本复制。
对照另一张图:在有真信号的特征上生成 300 个随机表达式,训练 IC 与验证 IC 的相关系数高达 0.965——因为大部分表达式的 IC 由它包含的真实特征贡献,这部分能跨期复制。

两张图合起来是一个判据:你的 GP 挖出的因子属于哪张图? 判别方法只有一个——严格隔离的样本外。
三层防御:把过拟合机器变成研究工具#
第一层:验证集早停 + 从不回看。 适应度只在训练段计算,验证段只用于监控。一旦最优个体的验证 IC 连续多代下滑而训练 IC 还在涨,立即停止进化。最阴险的失误是”看了验证集表现后调整算子集/参数再重跑”——这让验证集悄悄变成了训练集,你需要第三段从未接触的数据才能说话。
第二层:简约压力是过拟合税。 节点数惩罚不只是为了可读性——每个额外节点都是一个额外的自由度,而自由度就是拟合噪声的容量。3 节点的 (mom - rev) 泛化好不是偶然,是结构性的。
第三层:多种子稳定性检验。 换 5 个随机种子重跑进化。如果 5 次跑出的最优表达式在结构上高度相似(都含 mom 正向、rev 负向),说明进化收敛到的是数据里的真结构;如果 5 次结果五花八门、共同点只有”训练 IC 都不错”,那你挖到的是 5 份不同形状的噪声。表达式的稳定性比单次 IC 的高低更有信息量。
实践中还会撞上的三件事#
算子集是最强的先验。 本文给了 exp(-|x|) 算子,真实结构才有机会被表达。实践中算子集的选择(是否给时序算子 ts_rank、decay_linear?是否给横截面算子 cs_rank?)直接决定 GP 能探索的空间——这一步依然需要金融直觉,GP 只是把直觉从”具体公式”上移到了”算子词表”层面。WorldQuant 的 Alpha101 里大量使用的正是这类时序/截面算子组合。
适应度不该只有 IC。 生产级实现会用多目标:IC + IC 的 t 统计量 + 换手率惩罚 + 与已有因子库的相关性惩罚。最后一项尤其重要——挖出一个与你库里动量因子相关 0.95 的”新”因子毫无价值。
求值是瓶颈,缓存是刚需。 进化中大量个体是重复或近似重复的(精英保留 + 交叉都会复制结构)。对 repr(tree) 做记忆化缓存,本文实验的求值次数从 150×25=3750 次降到约 1400 次实际计算。面板更大时这决定了你能否把种群和代数开到有统计意义的规模。
收尾#
遗传编程把因子研究从”人想公式”变成”人设计搜索空间、机器搜公式”。本文的实验给出一个诚实的两面:它确实找到了超越所有单因子、逼近理论上限的组合(验证 IC 0.098 vs 上限 0.105),且简约压力让结果保持在 3 个节点的可读尺寸;但纯噪声实验同样清楚地演示了——搜索能力本身就是偏差来源,500 次随机尝试能把纯噪声的训练 IC 推到 0.018 并且还在涨。
用一句话总结使用纪律:GP 给你的每一个因子都应该被当作”嫌疑人”而不是”发现”——直到它通过了从未参与进化的样本外检验、通过了多种子稳定性检验、并且你能为它的结构讲出一个进化之前就成立的金融故事。
(本文实验为合成数据教学演示,IC 量级与真实市场不可直接类比;实盘因子挖掘还需处理生存偏差、交易成本与容量约束。不构成投资建议。)