gplearn遗传编程实战:符号回归、公式发现与特征生成
1. 先把问题定义清楚:gplearn 到底解决的是什么
前段时间有位做实验数据分析的朋友丢过来一份记录,三列输入、一列输出,问我能不能反推一个公式出来,直接写进技术报告里。我拿随机森林先跑了一遍,R² 能到 0.97,可打开模型一看全是分裂阈值,没法整理成一行式子;换成多项式回归去试,阶数一抬就过拟合,降下来又欠拟合,来回折腾半小时没个准信。这类“既要预测得准、又要能写成公式”的活儿,正好落在 gplearn 的地盘上。
gplearn 是一个把遗传编程(Genetic Programming,下文简称 GP)包装成 scikit-learn 风格 API 的 Python 库。它的思路跟普通回归完全不是一回事——它不训练一组权重,而是把候选数学表达式当成“个体”放进一个种群里,按适应度优胜劣汰,一代代交叉、变异,最后活下来的那个表达式本身就是模型。库里有三个入口:SymbolicRegressor做符号回归,SymbolicClassifier做二分类,SymbolicTransformer专门用来生成新特征。这三个类共享同一套进化引擎,参数名、调用方式、fit/predict习惯全部对齐 scikit-learn,上手成本比想象中低很多。
它适合谁?我觉得有三类人最该看看:一是做实验数据、工程经验公式、物理机理分析的,手上有一堆变量但不确定函数形式;二是做特征工程的,想从原始列里“长”出一些非线性组合特征喂给下游模型;三是对进化算法好奇、想找个能跑通的最小实现来研究 GP 内部机制的。反过来,如果你的数据是几十万行的高维稠密表、或者你要的是最高精度的预测结果,gplearn 基本不该出现在你的候选清单里,后面我会专门说为什么。
2. 遗传编程的核心机制:表达式树是怎么被“养”出来的
理解 gplearn 的关键,是先理解它内部的数据结构。它不存字符串形式的公式,而是把每个候选表达式表示成一棵程序树,树上的内部节点取自“函数集”(function_set),叶子节点取自“终结符集”——终结符要么是某个输入特征,要么是一个随机常数。比如表达式(X0 + X1) * X2,根节点是mul,左子树根是add,右子树根是X2,两个叶子是X0和X1。整棵树就是一个可以递归求值的程序。
这棵树在实际存储时用的不是对象指针,而是一个前缀记法的列表。这跟 Lisp 的 S-表达式是一个思路:从前往后扫,遇到几元函数就往后吃几个子树。这样做的好处是序列化极简单、比较和复制都是纯列表操作,交叉和变异也只是列表切片,性能上比对象树高一个量级。你训练完之后访问est._program.program拿到的就是这个列表,后文我会给出把它还原成人类可读公式的写法。
2.1 初始化:grow、full 和 half and half 三种长法
种群刚建立的时候,每个个体都是一棵随机树,树怎么长由init_method和init_depth两个参数决定。init_method有三个取值:grow、full、half and half(默认)。
full最“规整”:每个叶子的深度都严格等于init_depth的上界,所有树长得一样高,节点全用函数填满。这种方式生成的表达式结构完整,但容易一开始就堆得特别复杂,进化早期噪声很大。grow则宽松得多:沿着分支往下走,每个节点都有概率提前停下来变成一个叶子,所以树的深度参差不齐,浅的深的同时存在,种群多样性更好。
half and half就是各占一半,一半个体用full生成,一半用grow生成。默认选它是有道理的——纯full容易在早期陷入结构同质化,纯grow又会让种群整体偏浅、搜索不到复杂形式,混合起来在“探索”和“利用”之间取了个平衡。init_depth默认(2, 6),意思是深度随机落在 2 到 6 之间,这个区间对大多数中小规模问题足够了;如果你的目标函数明显是高阶的(比如含三四层嵌套),可以上调到(3, 8),但记得配合后文的简约惩罚一起用,否则膨胀会失控。
2.2 四类遗传算子:谁负责探索,谁负责微调
每一代里,gplearn 会按概率对个体做四种遗传操作之一,概率由四个参数控制。搞清这四者的分工,比死记默认值重要得多。
交叉(crossover)是主力,由p_crossover控制,默认 0.9。做法是选两个父代,各自随机挑一个子树,互换。它承担的是“组合已有好结构”的职责,也是种群能快速提升适应度的主要来源。
子树变异(subtree mutation)由p_subtree_mutation控制,默认 0.01。随机挑一个节点,把它整棵子树换成一棵新生成的随机树。这是“推倒重来”式的探索,破坏性大,所以概率给得很低。
提升变异(hoist mutation)由p_hoist_mutation控制,默认 0.01。随机挑一个节点,用它的某个子树替换掉整棵树的根——相当于把一段子程序“提拔”成主程序。这个算子的真实用途是抗膨胀,它天然会让表达式变短,是控制树规模的一道保险。
点变异(point mutation)由p_point_mutation控制,默认 0.01,同时还有个p_point_replace默认 0.05 控制每个节点被替换的概率。它只替换单个节点,比如把add换成mul,把X1换成X3。这是最精细的局部搜索。
四个概率之和必须小于等于 1,剩下的那部分叫繁殖(reproduction)——直接把父代原样复制到下一代。注意这是“默认行为”而不是“异常”,它保证了好个体不会因为代数推进而丢失。如果你把四个概率调到加起来正好等于 1,繁殖就消失了,种群收敛会更快但也更容易早熟。
2.3 适应度、简约系数与那个绕不开的“膨胀”问题
适应度决定谁被选中。gplearn 用的是锦标赛选择:每次随机抽tournament_size(默认 20)个个体,取其中适应度最好的一个作为父代。这个机制的好处是不用维护全局排序,且tournament_size天然调节选择压力——20 意味着从 20 个里挑 1 个,压力不小;调到 5 会温和很多,种群多样性保留得更好。
适应度怎么算,由metric参数指定。回归默认'mean absolute error',可选'mse'、'rmse'、'pearson'、'spearman';分类默认'log loss',也可用'accuracy'。这里有个容易踩的点:gplearn 内部知道指标方向,pearson/spearman/accuracy 是越大越好,误差类是越小越好,你不用自己做符号翻转。但如果你用make_fitness自定义,就必须显式传greater_is_better,传错了会得到完全相反的进化方向。
真正决定成品质量的是parsimony_coefficient。GP 有个众所周知的毛病叫膨胀(bloat):在没有约束的情况下,表达式的长度会随代数一路疯长,但适应度几乎不提升——多出来的节点在数学上可能是加零或乘一,纯属冗余。gplearn 的应对方式是给适应度加一个复杂度惩罚项:最终评分 = 原始适应度 +parsimony_coefficient× 程序长度。
这个系数默认 0.001,还可以设成字符串'auto',此时库会用袋外样本(out-of-bag)适应度做惩罚基准,原理来自一种基于 OOB 的简约压力方法。我的经验是:中小规模问题先用 0.001 试水;如果最终表达式还是长得离谱(比如超过 60 个节点),就往 0.003~0.01 加;如果模型简单得离谱、误差明显偏高,就往下调到 0.0001 甚至 0。这个参数没有普适最优值,它本质上是在“精度”和“可解释性”之间划一刀。
| 参数 | 默认值 | 作用 | 调大后的效果 |
|---|---|---|---|
population_size | 1000 | 每代个体数 | 搜索更充分,耗时线性增长 |
generations | 20 | 迭代代数 | 收敛更彻底,也更容易过拟合 |
tournament_size | 20 | 锦标赛抽样数 | 选择压力大,收敛快、多样性低 |
init_depth | (2, 6) | 初始树深度范围 | 起点更复杂,搜索空间更大 |
parsimony_coefficient | 0.001 | 复杂度惩罚 | 表达式更短、精度可能下降 |
max_samples | 1.0 | 每代采样比例 | 小于 1 相当于随机子采样,抗过拟合 |
const_range | (-1, 1) | 常数取值范围 | 决定能否自动拟合系数 |
3. 从零跑通第一个实验:完整可复现流程
理论说再多不如跑一遍。这一节我给一套能直接抄的代码,从造数据到解读结果全走通,中间每一步的意图我都会讲清楚。
3.1 安装与版本兼容的第一道坎
安装本身没什么花样,pip install gplearn就够了。真正的坑在依赖上:gplearn 这个库的维护节奏比较慢,长期停在 0.4.x 这个系列,而它依赖的 scikit-learn 在持续演进,内部校验接口改过几轮。结果就是某些较新的 scikit-learn 版本配上老版本 gplearn,会在fit()的第一步抛出跟参数校验相关的异常,看起来像是你数据的问题,其实纯粹是版本不匹配。
我的处理习惯是给这类“更新不频繁但依赖在动”的库单独建环境,装好之后先把版本组合冻下来,写进项目的依赖清单里。具体做法是先装一个你确定能用的 scikit-learn,再装 gplearn,然后立刻跑一个五行代码的冒烟测试,通了再往下做。别等到数据清洗、特征工程全做完,最后卡在库加载上,那种体验非常糟糕。
pip install gplearn# 冒烟测试:五行确认环境可用 import numpy as np from gplearn.genetic import SymbolicRegressor X = np.random.RandomState(0).uniform(-1, 1, (100, 2)) y = X[:, 0] ** 2 + X[:, 1] est = SymbolicRegressor(population_size=200, generations=5, random_state=0).fit(X, y) print(est._program)跑通了会打印出类似add(mul(X0, X0), X1)的东西。看到它,环境就过关了。
3.2 造数据、训练、看结果
我用一个自己构造的函数来演示,因为我知道正确答案,方便判断库有没有学对:y = X0² + X1 * 2 - 0.5。这里故意混了平方、乘法、常数三种成分,正好能考察function_set和const_range两块的配合。
import numpy as np from gplearn.genetic import SymbolicRegressor from sklearn.model_selection import train_test_split rng = np.random.RandomState(42) X = rng.uniform(-3, 3, size=(500, 3)) y = X[:, 0] ** 2 + 2.0 * X[:, 1] - 0.5 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.25, random_state=42 ) est = SymbolicRegressor( population_size=2000, generations=25, function_set=('add', 'sub', 'mul', 'div', 'sqrt'), metric='mean absolute error', parsimony_coefficient=0.0005, p_crossover=0.85, p_subtree_mutation=0.05, p_hoist_mutation=0.05, p_point_mutation=0.05, max_samples=0.9, const_range=(-2.0, 2.0), init_depth=(2, 5), feature_names=['x0', 'x1', 'x2'], random_state=7, n_jobs=-1, verbose=1, ) est.fit(X_train, y_train) print('训练 R²:', est.score(X_train, y_train)) print('测试 R²:', est.score(X_test, y_test)) print('最优表达式:', est._program)几个参数的选择我得解释一下。function_set里放了sqrt,是因为我预先知道目标里有平方项,开方和乘法组合能凑出幂次结构;如果完全不知道形式,用默认的('add','sub','mul','div')起步更稳妥,加太多算子会让搜索空间呈组合爆炸式增长。max_samples=0.9让每代只随机抽 90% 的样本去评估个体,这既是提速手段也是防过拟合手段——跟随机森林的 bagging 是一个思路,个体在略有差异的子集上被评价,选出来的表达式泛化性通常更好。const_range我从默认的 (-1,1) 放宽到 (-2,2),因为目标里的常数是 -0.5 和 2.0,范围太窄容易拖慢收敛。
verbose=1会打印每一代的情况,包括最优个体的长度和适应度,这个输出信息量很大,值得盯一眼。如果你看到长度从一代到下一代数翻倍、适应度只降一点点,那就该把parsimony_coefficient往上加了。
3.3 把程序对象翻译成人能看懂的公式
est._program打印出来的形式是add(mul(x0, x0), mul(x1, 2.0))这类前缀记法,能看懂但不好写进报告。gplearn 本身提供export_graphviz可以导出程序树,但要拿到干净的数学式子,我更习惯自己写一个十来行的递归解析器,把_program.program这个列表转成中缀表达式。这个做法的好处是不依赖任何外部库,运算符映射完全由我控制。
FUNC_MAP = { 'add': ('+', 2), 'sub': ('-', 2), 'mul': ('*', 2), 'div': ('/', 2), 'sqrt': ('sqrt', 1), 'log': ('log', 1), 'abs': ('abs', 1), 'neg': ('neg', 1), 'inv': ('1/', 1), 'max': ('max', 2), 'min': ('min', 2), 'sin': ('sin', 1), 'cos': ('cos', 1), 'tan': ('tan', 1), 'sig': ('sigmoid', 1), } def to_infix(program): """把 gplearn 的前缀列表还原成中缀字符串""" def parse(idx): token = program[idx] if isinstance(token, str) and token in FUNC_MAP: op, arity = FUNC_MAP[token] args, cursor = [], idx + 1 for _ in range(arity): expr, cursor = parse(cursor) args.append(expr) if arity == 1: return f"{op}({args[0]})", cursor if op in ('max', 'min'): return f"{op}({args[0]}, {args[1]})", cursor return f"({args[0]} {op} {args[1]})", cursor return str(token), idx + 1 expr, _ = parse(0) return expr print(to_infix(est._program.program))这段代码有一个细节需要注意:判断一个 token 是函数还是终结符时,我用的条件是“是字符串且在映射表里”。这是因为 gplearn 的常数终结符是浮点数而不是字符串,而add这类函数名始终是字符串,这样区分最稳。如果你把feature_names设成了['add']这种撞名的特征名,判断就会错乱——虽然概率极低,但知道这个坑总比踩了强。
另外_program对象上还有几个有用的属性:length_是节点总数,depth_是树深,raw_fitness_是没加惩罚的原始适应度,fitness_是加了简约惩罚之后的最终评分,oob_fitness_是袋外适应度。排查问题时这几个数字比最终表达式更能说明情况。
3.4 用 SymbolicTransformer 把进化结果变成新特征
如果你对“可解释公式”没兴趣,只想借 GP 的搜索能力造几个强特征喂给下游模型,那SymbolicTransformer才是主角。它的思路是:让种群进化若干代,先按适应度挑出表现最好的一批程序(hall_of_fame,默认 100 个),再在这个池子里做相关性去重,剔除彼此高度相关的那些,最后保留n_components个差异最大的程序作为特征生成器。
from gplearn.genetic import SymbolicTransformer from sklearn.linear_model import Ridge gp_feat = SymbolicTransformer( population_size=1000, generations=15, hall_of_fame=100, n_components=10, function_set=('add', 'sub', 'mul', 'div', 'sqrt', 'log'), metric='pearson', max_samples=0.9, parsimony_coefficient=0.001, random_state=7, n_jobs=-1, ) gp_feat.fit(X_train) X_train_new = np.hstack([X_train, gp_feat.transform(X_train)]) X_test_new = np.hstack([X_test, gp_feat.transform(X_test)]) model = Ridge(alpha=1.0).fit(X_train_new, y_train) print('加特征后 R²:', model.score(X_test_new, y_test))这里metric='pearson'是有意选的,因为SymbolicTransformer的目标是“生成与目标相关性强的特征”,用相关系数当适应度比用误差更直接。n_components=10意味着最终只留 10 个程序,hall_of_fame=100是先取 100 个再筛到 10 个,这种“宽进严出”的两段式筛选能明显减少冗余特征。
注意:
transform输出的特征跟原始特征的量纲可能差好几个数量级,因为 GP 会自由地做乘除。下游如果用的是带正则的线性模型,务必先做标准化,否则正则项会被大尺度特征带偏。
4. 参数调优:真正决定结果的就那么几个
跑通之后,所有人的下一个问题都是“参数怎么调”。gplearn 的参数表看着长,但按影响力排个序,你会发现真正需要花心思的就五个左右。剩下的用默认值完全够用。
4.1 function_set 是搜索空间的边界,也是精度的天花板
这个参数最容易被忽视,但它决定了算法有没有可能找到正确答案。举个极端的例子:目标函数是y = x0 * x1,你把function_set设成('add', 'sub'),那无论跑多少代都学不出来,因为乘法根本不在可用的构建块里。同理,如果你的场景涉及周期现象,加sin/cos是必须的;如果涉及倒数关系(比如物理里的反比定律),就必须加inv或div。
但反过来也不是越多越好。可用函数的数量和树的深度一起决定了搜索空间大小,加一个算子大致相当于让空间翻一倍。我的做法是先用最小集跑一遍,看误差卡在什么水平,如果明显下不去了、且残差里有结构性规律,再加一两个最有可能的算子重跑,对比两次结果。这种“增量加算子”的策略比一上来堆满 15 个算子省算力得多,也更容易解读结果。
顺带说一句保护性语义,这是 gplearn 一个贴心设计:div在分母为 0 时返回 1,log对非正数返回 0,sqrt对负数返回 0,inv对 0 返回 0。这些“兜底”避免了进化过程中大量个体因为除零或非法定义域直接崩溃,但它也意味着你看到的表达式在数学上未必严格成立——如果最终公式里出现了sqrt(...)而括号内可能为负,实际含义是被截断的,这一点在写技术报告时必须留意。
4.2 算力预算:先把账算明白再点运行
gplearn 慢,这是它最常被吐槽的点,但慢得是可预期的,算一算就知道。
每一代要评估population_size个个体,每个个体要做一次前向传播,前向的代价大致跟树的节点数成正比,而节点数又跟init_depth和膨胀程度有关。总评估量约等于population_size × generations × n_samples × 平均节点数。默认的 1000 × 20 × 1000 样本 = 2×10⁷ 次单树求值,如果平均每棵 30 个节点,就是 6×10⁸ 次基本运算。纯 Python 实现跑这个量级,单机几分钟到十几分钟很正常。
所以提速的路子很明确:降population_size比降generations更划算。因为代数决定的是“能不能收敛”,砍代数往往直接导致没收敛完;而种群规模决定的是“每代搜索多宽”,砍一半通常只是结果稍差一点,不会崩盘。我常用的起步配置是population_size=1000, generations=20,如果要快速试错,先降到500 × 10摸个底,方向对了再放大。n_jobs=-1一定要开,它会把个体评估并行化,多核机器上收益接近线性。max_samples设到 0.8~0.9 也是常用的提速手段,顺带还能抗过拟合。
4.3 自定义适应度:make_fitness 的正确用法
内置指标覆盖不了所有场景。比如你只想惩罚低估而不在乎高估(库存预测、安全余量类问题),或者你的评价指标是某种业务加权误差,这时候就要自己写。gplearn 提供make_fitness来包装自定义函数,签名必须是(y, y_pred, sample_weight)三个参数,返回值是标量。
import numpy as np from gplearn.fitness import make_fitness from gplearn.genetic import SymbolicRegressor def _weighted_mae(y, y_pred, w): return np.average(np.abs(y - y_pred), weights=w) wmae = make_fitness(function=_weighted_mae, greater_is_better=False) est = SymbolicRegressor( metric=wmae, population_size=1000, generations=20, random_state=1, n_jobs=-1, ).fit(X_train, y_train, sample_weight=np.ones(len(y_train)))两个必须注意的点。第一,greater_is_better千万别填错,填反了进化方向就完全颠倒,你会看到适应度越来越高但预测越来越离谱,排查起来极其费时间。第二,自定义函数的执行频率等于“个体数 × 代数”,所以函数体内不要有任何print、日志或者临时数组分配,我见过有人在里面顺手打了行日志,结果整个训练慢了五倍。
4.4 warm_start 与早停:把长任务拆成可中断的小段
warm_start=True是个很实用但少有人用的开关。开启它之后,再次调用fit会接着上一次的种群继续进化,而不是从头开始。这对调参非常友好:先用 10 代跑一遍看看方向,觉得有戏就把generations改大再fit一次,等于续跑。配合stopping_criteria使用效果更好——这个参数默认 0.01,含义是当某一代的最优个体适应度与历史最优的差距小于这个比例时,认为已经收敛,提前停止。对于有明确收敛行为的问题,它能把训练时间砍掉一大截。
提示:
warm_start续跑时不要改动function_set、init_depth这类结构性参数,否则种群内部会不一致,行为未定义。改generations、parsimony_coefficient这类“运行时参数”是安全的。
5. 实战排查:那些文档里没写、但一定会遇到的问题
5.1 每次跑出来的公式都不一样,到底该信哪个
这是新手最常见的困惑,也是 GP 的固有属性——它本质上是随机搜索,random_state不固定的话,两次结果可以差很远。我的处理方式是:固定随机种子只用于调试,评估模型时必须跑多次看分布。具体做法是拿 5 到 10 个不同的种子各跑一遍,记录每次的测试集误差和表达式长度,然后看两个指标:平均误差有多低,误差的方差有多大。如果方差极大(比如有的 R² 0.95、有的 0.6),说明这个问题对 GP 来说太不稳定,要么加大种群规模,要么换个方案。
还有一个细节:不要拿训练集上的最优表达式直接当结论。GP 特别擅长在训练集上“凑”出高分表达式,尤其是当样本量小、const_range又很宽的时候。一定要留一份从来没参与过训练的测试集做最终判断,这一步没有任何捷径。
5.2 表达式看起来挺像回事,但预测一塌糊涂
这种情况我遇到过好几次,原因基本集中在三处。第一是数据泄露:如果你的特征里有跟目标高度相关的派生列,GP 会迅速找到这条捷径,生成一个形式上简单、实际上毫无因果意义的表达式。排查方法是把可疑特征逐个去掉重跑,看误差是否大幅变化。第二是量纲问题:GP 会自由地做乘除,如果输入特征量级差了几个数量级(比如一个在 0.001、一个在 10000),进化过程中会反复出现数值剧烈波动的个体,最终结果对输入扰动极其敏感。解决办法是训练前统一标准化。第三是 NaN 和无穷值:gplearn 不接受缺失值,fit时会直接报错,但如果你的数据里藏了inf,它可能静悄悄地污染整个适应度计算,最终表达式看着正常、算出来全是nan。养成训练前先做一遍np.isfinite检查的习惯。
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
| 报错提示含 NaN | 输入数据有缺失值 | 填充或删除,gplearn 不支持缺失 |
| 预测值出现 inf 或 nan | 输入含 inf,或除零路径未被保护 | 检查np.isfinite,限制const_range避开 0 |
| 表达式极长但精度不高 | 膨胀失控 | 上调parsimony_coefficient到 0.003~0.01 |
| 表达式极短但误差大 | 惩罚过重 | 下调parsimony_coefficient或设 0 |
| 结果每次差异巨大 | 种群太小或随机性未控制 | 加大population_size,多跑几个种子看分布 |
| 训练集完美、测试集崩盘 | 过拟合 | 降generations,max_samples设 0.8,加正则 |
| 训练耗时远超预期 | 函数集过大或树膨胀 | 精简function_set,降init_depth上界 |
5.3 内存和速度的边界在哪
gplearn 在内存上其实不算凶,因为程序是列表存的,1000 个个体、每个几十个节点,撑死也就几十兆。真正吃资源的是n_jobs=-1配合大种群时的进程副本,每个进程都会复制一份数据。如果你的数据集本身就有几百兆,n_jobs开到 8 很可能直接把内存打满。这种情况我一般把n_jobs降到 4,或者先用max_samples做子采样再并行,把单份数据的体积压下来。
速度方面还有一个容易被忽略的因素:feature_names设不设都不影响计算,但会影响可读性,而可读性直接影响你排错的速度。我现在的习惯是永远显式传feature_names,表达式里出现x0、流量、温度这种语义名,比X0、X7、X13好排查太多了。
6. 几个实际场景里的用法与取舍
6.1 从实验记录里挖候选经验公式
这是 gplearn 最匹配的场景。流程我一般这么走:先做相关性分析筛掉明显无关的列,把变量数压到 5 到 8 个以内(再多搜索空间就爆炸了);然后根据领域常识定function_set,比如机械类问题通常用add/sub/mul/div/sqrt,含有周期激励的加sin/cos;接着用较小的种群跑几轮,看能不能把误差压到一个有意义的水平。
关键在于把 GP 的输出当成“候选假设”而不是“最终答案”。它给出的式子往往带点冗余(比如某个系数是 0.983 而不是 1),这时候不要照单全收,而是看结构——如果它反复给出sqrt(x0 * x1)这种形式,那就是一个强烈的信号,说明变量间可能存在某种乘积开方关系,你可以顺着这个方向去做量纲分析或者物理推导,把它修正成理论上站得住的式子。这种“GP 提假设、人做验证”的配合方式,比指望它一步到位靠谱得多。
6.2 当特征工厂用:和下游模型配合
SymbolicTransformer生成的表达式特征,最合适的搭档是那些对非线性组合不敏感的模型,比如线性回归、逻辑回归、或者加了强正则的树模型。我一直觉得这个组合有点反直觉但很好用:GP 负责把原始特征非线性地组合成几十个新维度,线性模型负责在这个新空间里做加权。相当于用进化算法自动完成了一部分特征工程,把原本要靠人工试错的工作交给搜索。
实操上有个经验:n_components不要设太大,10 到 20 个足够,设到 100 个的时候下游模型基本学不动,而且大部分特征高度冗余。另外hall_of_fame要保持比n_components大一个量级(比如 100 对 10),去重环节才有足够的候选池可以挑。
6.3 什么时候该果断放弃它
我现在会明确在一开始就排除 gplearn 的几种情况:样本量超过十万行(训练时间长到不可接受)、特征维度超过二十(搜索空间组合爆炸)、目标函数本身没有简洁的解析形式(比如图像、文本这类高维非结构化输入)、以及对预测精度的要求是“榨干最后 1%”(这种活儿该交给梯度提升类模型)。GP 的价值在于可解释性和结构发现,不在绝对精度。认清这一点,能省下大量无效尝试的时间。
反过来,样本量在几百到几千、特征在十个以内、且你确实需要一个能写进文档、能被人读懂、能进一步做理论分析的公式,那 gplearn 几乎是 Python 生态里最合适的选择——它把遗传编程那套东西的入门门槛降到了跟调RandomForestRegressor差不多的水平。
我自己用下来的体会是,gplearn 更像一个“假设生成器”而不是“预测器”。把它当成一个能陪你一起猜公式的助手,给它合理的搜索边界、给足算力、然后耐心看几轮结果,它经常能给出一些你自己想不到的组合形式。但如果你指望它一次fit就吐出正确答案,那大概率会失望——这东西的脾气,跟它模仿的自然进化一样,需要你投入时间去引导,而不是下个指令就等着收货。跑之前先把random_state、parsimony_coefficient、max_samples这三样调明白,能避掉八成让人抓狂的问题。