演化循环每轮做同样的三件事:打分、排序、变异。循环结束,你手里只剩程序和分数。但有个地方不对劲——分数在排序完的那一刻就死了。它不是被存下来,也不是被拆开;它是被当作一次性比较键消费掉,下一轮循环的 score_fn 调用直接把它整个覆盖。
ExpEvolve 那篇论文(arXiv 2610.29303,10 月 21 号挂出来的)管这个叫样本效率问题。丢掉这层信息的代价,最差能差到 5 倍。这个数先记住。真正值得拆的是它提的那个东西在代码里长什么样。这篇先把最朴素的演化循环跑起来,看每一步扔掉了什么,再把论文里的骨架补上。
先写最小能跑的版本:
def evolve(score_fn, mutate_fn, n_gens=100, pop_size=50):
pop = [random_program() for _ in range(pop_size)]
best_score = -float('inf')
for gen in range(n_gens):
scores = [score_fn(p) for p in pop]
best_score = max(best_score, max(scores))
# top-k 选择,然后随机变异
parents = sorted(zip(pop, scores), key=lambda x: -x[1])[:pop_size // 2]
pop = [mutate_fn(p) for p, _ in parents]
return best_score
注意 scores 在这里的生命周期。它第 5 行被算出来,第 7 行被用来给 zip 排序,然后就没有然后了。循环顶部下一次打分直接用同样的变量名盖掉它。程序字符串保留了下来,分数只留了个 best_score 这样的全局标量。但没有任何地方记录,那个高分程序里到底是哪一块把分拉起来的。
演化系统记住的是"谁好",丢掉的是"为什么好"。
这听上去只是记账问题。往下挖一层,它是控制单元的问题。分数在这个循环里的全部工作,是当一扇门:这一轮谁留下谁淘汰。门的判断做完就失效,不积累成下一轮可用的知识。于是每轮都是新的开始——除了程序和分数,循环不携带任何历史。
"为什么好"这三个字说起来轻,做实验的人知道它有多重。一个研究者做完一批实验,不会只把跑得最好的那组配置抄下来。他脑子里沉淀出来的是一个解释:哪些因素在驱动结果差异。这层东西比任何单次实验都耐用,因为它能跨实验复用,解释新的配置为什么行或不行。ExpEvolve 说,演化循环缺的正是这个可持久保留的解释性控制单元。它管这个叫"思想因子"。
思想因子的定义是:从程序中抽取、能解释不同程序得分差异的高层因素。换句话:不是"程序 A 得 0.9 分",而是"A 用了因子 f1,而 f1 在多数情况下把分往上拉"。这个定义很关键——它要求因子和分数之间是解释关系,不是简单描述关系。描述是"程序 A 有 f1,得 0.9";解释是"f1 在多数情况下把分往上拉",后者能跨程序外推。
到这一步,ExpEvolve 的贡献就能说清了:它把演化的控制单元从程序字符串挪到了思想因子。不是又一个变异算子,是循环的记账方式变了。下面把这个挪动在代码里做出来。
def extract_ideas(pop, scores, k=3):
features = np.array([program_features(p) for p in pop])
# 每个特征维度与分数的相关系数
corr = np.corrcoef(features.T, scores)[:-1, -1]
top_dims = np.argsort(-np.abs(corr))[:k]
return top_dims # 思想因子 = 高相关特征维度
这个版本很糙,但它把机制的形状立起来了:思想因子不是玄学,是一个可计算的函数,输入是程序和分数,输出是"哪些因素在驱动分数"。论文里 extract_ideas 的完整实现没给,但框架就是这一步——把程序投影到低维空间,找与分数相关的维度。有统计基础的读者可能已经看出来了,这一步本质是拿特征维度和分数求相关,取绝对值最大的 k 个维度。思想因子就是这些高相关维度。
有了这个,循环就多了一个动作:利用和探索分离。利用是知道了哪个因子好,往这个因子的方向深挖;探索是发现当前因子都挖不动了,主动换一个新因子。朴素版本里的变异是盲的,一步变异撞上什么是什么,没有"方向"和"换方向"的区分。带思想因子的版本长这样:
def evolve_with_ideas(score_fn, mutate_fn, n_gens=100, pop_size=50):
pop = [random_program() for _ in range(pop_size)]
ideas = None
for gen in range(n_gens):
scores = [score_fn(p) for p in pop]
if ideas is not None and not plateau(scores):
# 利用:在好因子附近深挖
parents = select_by_factor(pop, scores, ideas)
else:
# 探索:按因子多样性采样,换思想因子
parents = select_diverse_factors(pop, scores, ideas)
pop = [mutate_guided(p, ideas) for p in parents]
ideas = extract_ideas(pop, scores)
return best_score
这里 plateau(scores) 这个函数看起来人畜无害,其实它决定了你什么时候从利用切换到探索——切早了浪费因子,切晚了卡死在局部最优。论文没给这个阈值怎么设,我实现的时候调了半天,最后用了滚动窗口里的分数方差小于某个极小值就判 plateau。这个细节不重要,重要的是它让循环第一次有了"换方向"这个动作。
追一次执行就能看出差别。假设任务是判断一个整数是不是质数,分数是测试集准确率。前几代两个版本都在乱撞。某个程序偶然写对了"先试 2,再试 3";分数涨了。朴素版本留下这个程序字符串,下一次变异可能顺手把"试 2"改成"试 7",把好模式打烂——它不知道"小素数检测"这个局部几乎就是全部贡献。带思想因子的版本会先抽出"小素数检测"这个因子,让后续变异围绕这个因子精修——调顺序、扩范围。字符串每次都在变,因子没变,方向没变。这个差别的累积,就是论文说的那 5 倍。
写到这里我得点出一个坑。思想因子如果只是事后读程序写的一段总结——比如让 LLM 对着高分程序输出一句"这个程序用了小素数检测"——那它表达的是程序和分数的相关性,不是因果性。在监督学习里,混淆相关和因果已经很糟了;在演化搜索里更致命,因为探索阶段是靠因子换方向的。如果因子是伪相关,你会被带到沟里。论文给了理论条件,刻画了什么时候借助思想因子能提升样本效率:大意是如果分数函数能通过低维的思想空间分解,在思想空间里搜索的样本复杂度比在程序空间里低一个数量级。前提是 extract_ideas 真的抽到了因果相关的因子,而不是事后编的故事。这个前提怎么验,论文里做了分析,但我没读到底,先存疑。
实验部分我快速过一遍:11 个开放式数学任务,样本效率提升最高 5.0 倍;迁移到另外 4 个 LLM 和 MLS-Bench 的 6 个机器学习任务,提升还在。这些数字我先放一边。真正值得说的是跨模型、跨任务迁移这个事实——它说明这个改进改的不是某个模型的能力,是循环本身的控制结构。模型换了,循环不变,提升还在。
我其实也栽过同一个坑。之前自己写过一版演化搜索,觉得"只留最优程序不够",就加了个记忆库:把所有历史上高分程序都存着,每轮随机抽几个回来变异。当时以为这就是经验。后来才看清:那个库还是程序,不是思想。它解决多样性,没解决归因。ExpEvolve 的思路比那个版本精确得多:经验要存成可解释的因子,不是存一堆结果。
到这一步,结论可以压回一句:演化式编程丢掉的那一半信息,是分数背后的因子结构。ExpEvolve 把这个结构显式抽出来,当作控制单元塞回循环,于是探索和利用第一次有了分岔口。
想再深一层,有两个入口。一是去翻论文里的框架定义,看那个理论条件的完整形式。二是自己动手,把 extract_ideas 从控制流特征换成一个学出来的 embedding,在同一个玩具问题上跑一遍,观察样本效率到底差多少。第二个入口更有意思——因为到那个时候,你会真的踩到"思想因子是因果的还是相关性的"这个边界,不是看懂的,是跑出来的。
这块地我据过,往下还有的是。