神经进化这个词听起来像是实验室里的东西,但真正动手搭过一个能跑的平台之后,你会发现决定它能不能出结果的,往往不是种群开多大、代数跑多少,而是那个最不起眼的部件——基因型突变器。梯度下降有成熟的自动微分框架兜底,符号写反了顶多收敛慢一点;神经进化不一样,突变器写歪了,搜索过程会悄无声息地退化成随机漫步,你盯着适应度曲线看了半天还以为是自己参数没调好,实际上是种群早就塌成一坨了。这篇东西我想把"简单神经进化平台的基因型突变器开发"这件事从头拆一遍:它是什么、要解决什么问题、核心算子怎么实现、参数怎么标定、哪里最容易踩坑。适合已经跑通过一次神经进化 demo、打算自己攒一套小平台的人看,也适合做强化学习实验、想用进化当备选方案的同学参考,代码层面只要求你会写 Python 和用过 numpy,不涉及太深的进化计算理论。
1. 突变器在神经进化里的真实位置
1.1 突变器不是"随机改几个数字"那么简单
很多人第一次写突变器,脑子里想的是"给权重加个随机噪声就完事了",代码十行就写完,然后跑起来发现种群在十几代内就趋同了。问题不在于随机数不够随机,而在于突变器承担的角色被严重低估了。在神经进化这套框架里,突变器是唯一一个从"已有解"里生成"新解"的部件,它同时兼任了两个互相拉扯的职责:一边要保证后代和父代足够相似,让好的基因结构能被保留下来;另一边要保证后代和父代有足够差异,让搜索不至于原地打转。这两件事天然矛盾,所以突变器的本质是一个探索与利用的调度器,而不是一个噪声发生器。
我在早期版本里就犯过这个错误。当时图省事,给所有权重统一加了标准差 0.5 的高斯噪声,结果第一代还行,跑到第五代左右,适应度最好的个体权重绝对值已经膨胀到几十,网络的输出全被推到了激活函数的饱和区,梯度(这里指进化意义上的适应度变化)基本为零,等于把搜索空间自己堵死了。后来把噪声改成相对于基因自身尺度的扰动,才有了像样的曲线。这件事让我确定了一条原则:突变器必须知道自己在改什么,不能对所有权重一视同仁。
更具体地讲,突变器的输出质量直接决定了三件事:种群的多样性能否维持、优良基因能否积累、搜索能否在合理代数内收敛。这三件事没有一个是能靠"调大突变率"解决的,调大只会让它更像随机搜索,调小又会让它退化成局部爬山。所以真正的工作量在实际的算子设计、参数标定和统计验证上,写代码本身只占很小一块。
1.2 基因型与表现型的边界必须划清
在动手之前,有一个概念必须先拎清楚:基因型(genotype)和表现型(phenotype)是两层东西。基因型是你做突变操作的那份数据结构,表现型是这份数据被解释成一个可运行的神经网络之后的样子。这两层分开的意义在于,你的突变器只需要对基因型负责,它完全不需要知道这个基因组最后会被展开成几层、多少个神经元。耦合一旦发生,后面想换网络结构或者换编码方式,突变器就得整个重写。
我现在的做法是让基因组只暴露最少的接口:一组带唯一标识的基因位点、每个位点的浮点值、一个启用标记、以及一个记录出生代数的字段。突变器拿到这个结构,只做三件事:挑选位点、修改数值、添加或禁用位点。至于这些位点怎么映射成权重矩阵,是解码器(decoder)的事。这个分层带来的直接好处是,同一套突变器可以同时服务于前馈网络、循环网络、甚至是一些非神经网络的结构,比如决策树参数,只要解码器写对就行。
还有一个隐性收益是可测试性。因为突变器不依赖网络的前向传播,你可以脱离整个训练流程单独对它做统计测试——比如跑一万次突变,看看位点被选中的频率是不是接近设定的突变率,看看数值变化的分布是不是符合预期。这个能力在后面排查问题时价值极高,后面第 4 节会详细讲。
1.3 三条我认为不能破的设计底线
第一条底线是可复现。突变器的所有随机性必须来自一个显式传入的随机数生成器对象,绝不能在里面偷偷调用全局的随机函数。这一条听起来像洁癖,但只要你有过一次"同一个种子跑出两个不同结果"的经历,就再也不会在这上面省事。种群初始化、选择、突变三个环节共用同一个随机源还是各自独立,也需要提前定好,否则复现的时候会很难对齐。
第二条底线是不破坏基因组的合法性。突变可以改数值、可以加位点、可以禁用位点,但不能产生解码器无法解释的状态,比如引用了不存在的节点、产生了自环连接、或者让某个必需参数变成 NaN。我一般会在突变器内部加一个轻量的合法性检查,或者在解码阶段做兜底修复,两者选其一,但必须有一层。
第三条底线是突变强度可调。突变率和突变强度(噪声尺度)是两个独立维度,混在一起调是最容易出问题的。突变率决定"有多少位点被碰",突变强度决定"被碰的位点改多少"。这两个参数在搜索的不同阶段应该有不同的取值,所以它们的配置必须能从外部注入,而不是硬编码在函数体里。
2. 基因型编码方案怎么定
2.1 扁平向量与结构化基因图的取舍
编码方案的选择会直接决定突变器能做什么、不能做什么。最常见的两种是扁平权重向量和结构化基因图。扁平向量就是把所有可训练参数按固定顺序拉成一个一维数组,突变器的工作就是在这个数组上撒点。它的优点是实现极简、内存紧凑、向量化友好,几百个个体一起突变也毫无压力;缺点也很明显——它不支持拓扑变化,网络的层数和每层宽度在初始化时就被钉死了,突变只能在给定的参数空间里游走。
结构化基因图则是把每个连接、每个节点都当成独立对象,用唯一编号管理,突变器可以新增连接、拆分连接、删除节点。这种方案的能力上限高得多,NEAT 那一系的工作就是建立在这个基础上的,代价是实现复杂度成倍上升,你还需要处理创新编号、结构冲突、跨物种交配等一系列问题。
我的建议是按阶段选。如果你只是想把神经进化这条链路跑通,验证它在你手上的问题上到底有没有效果,扁平向量足够用了,几百行代码就能出一个能看的版本。等你确认了这条路可行,再往结构化基因图上迁。一上来就写全套 NEAT,很可能在调试创新编号的过程中就放弃了。
补充一句,扁平向量也不是完全不能做结构突变。一个折中方案是预留结构位:把可能的最大网络规模固定下来,用一个启用掩码控制哪些连接生效,结构突变就变成了翻转掩码。这个方案在中等规模的问题上表现不错,实现难度介于两者之间。
2.2 基因位点的元信息别省
无论选哪种编码,每个基因位点上都建议挂几个元信息字段,它们在后期的排查和优化中会帮上大忙。我最常用的三个是:唯一编号、出生代数、启用状态。唯一编号用于跨代追踪,出生代数用于实现"新基因保护",启用状态用于软删除。
新基因保护这一条值得展开说。结构突变刚加进来的连接,权重通常是随机初始化的,这个随机值大概率是坏的,会拖累当前个体的适应度,导致这个有益的结构创新在下一代就被淘汰掉。解法是给新生基因一个保护期,在保护期内它的突变率更低(避免被进一步破坏),或者它的权重不参与适应度评估的惩罚。这个技巧在结构化编码里几乎是必选项,在扁平编码里也能用——比如对新启用的连接给一个较小的初始尺度。
出生代数还有一个用途是统计。跑完一轮实验之后,你可以统计种群中基因的平均年龄,如果这个值一直很小,说明突变在不断产生新基因但都活不下来,反过来如果一直很大,说明突变根本没在产生有效的新结构。这个指标比单纯看适应度曲线信息量大得多。
2.3 编码方案会反过来约束算子设计
这里有个容易被忽略的耦合关系:你选了什么样的编码,就决定了哪些突变算子是有意义的。扁平向量上做"加节点"突变是没意义的,因为向量长度固定;结构化基因图上做"整体加高斯噪声"也是很别扭的,因为不同连接的量纲可能不一样。
我在实际项目里用的是一套分层编码:底层是连接的权重和偏置,用扁平向量存;上层是拓扑描述,用紧凑的邻接结构存。突变器分成两个子模块,参数突变器只管底层,结构突变器只管上层,两者独立配置、独立调参。这样做的好处是调参时可以分开做实验——先固定拓扑只调参数突变,看清楚参数层面的搜索行为,再打开结构突变看增益。混在一起调,你永远不知道性能变化是哪个模块带来的。
顺带提一个实操细节:基因组的深拷贝必须做干净。突变操作一定是"基于父代产生新个体",绝不能原地修改父代对象。我见过不止一个实现是直接在原对象上改,结果种群里的个体互相污染,几代之后所有个体变成同一个东西。Python 里用copy.deepcopy最保险,性能敏感的话手写一个拷贝方法,但一定要覆盖到所有可变字段。
3. 突变算子家族与参数标定
3.1 参数突变:高斯扰动与重置要搭配使用
参数层面最常用的两个算子是高斯扰动和均匀重置。高斯扰动的形式是w_new = w_old + N(0, sigma),它的特点是改动幅度小、方向连续,适合在已有解的邻域做精细搜索。均匀重置是w_new = U(-1, 1),直接把权重拉回初始分布,特点是跳得远,适合把个体从局部最优里踹出来。
只用高斯扰动的问题是会陷在局部最优里出不来,因为它的步长分布集中在零附近,大跳变的概率极低。只用重置的问题是完全丢掉了父代的信息,退化成随机搜索。所以实践中通常是按比例混合:大部分被选中的位点走高斯扰动,小部分走重置。我一般把重置比例设在 5% 到 15% 之间,具体取决于问题的崎岖程度。
高斯扰动的标准差怎么定,这是最需要解释"为什么"的地方。固定标准差的问题前面说过,会让权重量级失控。我采用的是相对尺度方案:
sigma_i = sigma_base + sigma_rel * |w_i|其中sigma_base是基础步长,保证接近零的权重也能被推动;sigma_rel是相对系数,让大权重的扰动幅度按比例放大。举个具体数字,sigma_base = 0.02、sigma_rel = 0.08时,一个权重为 1.0 的连接,其扰动标准差是 0.10;权重为 5.0 时是 0.42。这个设计的思想是让扰动在对数尺度上大致均匀,避免小权重被噪声淹没、大权重纹丝不动。
还有一个变体是加性噪声改成乘性噪声,即w_new = w_old * (1 + N(0, sigma)),这种形式在权重接近零时会自动收敛,不会产生符号翻转。两种都行,乘性噪声更温和,加性噪声探索性更强,看你的问题需要哪一种。
3.2 结构突变:加连接、加节点、剪枝
结构突变是神经进化相对于固定拓扑方法的核心优势,也是最容易写出问题的部分。三个基本算子各有各的门道。
加连接:在当前未启用的连接候选里随机挑一条启用,权重初始化为一个很小的随机值(我通常用U(-0.1, 0.1)或者N(0, 0.05))。初始值为什么要小?因为新连接刚进来时和现有网络的配合是未知的,给一个大权重很可能直接破坏已有的功能,而小权重相当于"先接上试试",后续的进化会慢慢调整它。
加节点:选一条已有连接,把它拆成两段,中间插入一个新节点,原连接的权重分配给两段,新节点的偏置初始为零。这个操作有一个关键细节——拆分时权重的分配方式。常见做法是把原权重乘以一个接近 1 的系数分配给其中一段,另一段初始化为 1,这样新节点在刚插入时近似于恒等映射,对网络输出的扰动最小。如果两段都随机初始化,等于把一个好好的连接炸掉了。
剪枝:禁用一条连接或一个节点。这个算子的价值经常被低估。没有剪枝,网络结构只会单调膨胀,算力消耗线性上升,而性能未必提升。剪枝相当于给结构搜索加了一个正则项。我的经验是把剪枝概率设置得比加连接略低一点,比如加连接 3%、剪枝 1.5%,让结构整体上偏向增长但保留收缩能力。
这三种算子的触发概率需要分开配置。我的一般配置是:每次繁殖时,整体触发结构突变的概率 20% 左右,触发之后在三类算子之间按比例分配,加连接 50%、加节点 25%、剪枝 25%。这个比例不是金科玉律,但作为起点是够用的。
3.3 突变率、突变强度与自适应调整
突变率(有多少位点被选中)和突变强度(选中后改多少)必须分开理解,这一点前面提过,这里给出具体的标定方法。
参数突变率的典型区间是 1% 到 10%,含义是"每个权重被选中的概率"。设一个网络有 200 个参数,突变率 5% 意味着平均每次突变改动 10 个权重。这个数量级是合理的:改太少搜索效率低,改太多等于把父代信息全丢了。有个经验公式可以参考:初始突变率设成1/sqrt(N),N 是参数个数。200 个参数时约等于 7%,和上面的区间是吻合的。
结构突变的概率要低得多,通常 1% 到 5%。原因很简单,结构变化对适应度的影响是剧烈的,频率高了种群会一直在剧烈震荡,适应度曲线会呈现锯齿状。
自适应调整这块,最经典的是"五分之一成功法则":统计一段时间内突变产生更优后代的成功率,如果高于 1/5,说明步长偏保守,增大强度;低于 1/5,说明步长太大,减小强度。这个规则在连续优化里很好使,在神经进化里要注意样本量——个体适应度评估本身是有噪声的(尤其是随机环境下的强化学习任务),统计窗口太小会被噪声带偏,我一般用 20 到 50 个个体作为统计窗口。
还有一个更简单的自适应策略是代数衰减:早期的突变强度大一些,鼓励探索;后期逐步降低,鼓励收敛。比如sigma_rel从 0.12 线性衰减到 0.03,跑 100 代。这个方案实现简单、效果稳定,我倾向于先上这个,跑通之后再考虑更复杂的自适应。
4. 把突变器写出来:接口、实现与自测
4.1 接口设计与随机源注入
接口设计的核心原则是突变器不持有状态,所有上下文从参数传入。突变器对象本身应该是无状态的(或者说状态只有配置),每次调用接收基因组、当前代数、随机源,返回新基因组。这样设计的好处是突变器可以在多线程或多进程环境里安全复用,也方便做单元测试。
随机源的注入方式是关键。我统一用 numpy 的Generator对象,通过np.random.default_rng(seed)创建,把种子在实验配置里显式指定。注意不要用旧的RandomState,Generator的算法更稳定,且未来不会变。种群初始化、选择、突变这三个环节,我建议各自用一个独立的 Generator,种子从主种子派生(比如主种子 + 偏移量)。这样做的好处是,你只改突变模块的代码时,种群初始化的随机序列不会跟着变,实验对比才有意义。
配置结构我一般用一个简单的 dataclass 装起来,字段包括参数突变率、重置比例、基础步长、相对步长、结构突变概率、三类结构算子的内部权重、基因值的上下界。把这些参数全部外置的好处是,做参数扫描的时候只需要改配置文件,不用动代码。
4.2 核心实现
下面是我现在用的简化版本,扁平编码加可选的拓扑开关,核心逻辑都在里面。
import copy import numpy as np from dataclasses import dataclass @dataclass class MutatorConfig: p_rate: float = 0.05 reset_ratio: float = 0.10 sigma_base: float = 0.02 sigma_rel: float = 0.08 w_min: float = -5.0 w_max: float = 5.0 p_struct: float = 0.20 struct_split: tuple = (0.5, 0.25, 0.25) class Mutator: def __init__(self, cfg: MutatorConfig, rng: np.random.Generator): self.cfg = cfg self.rng = rng def mutate(self, genome: dict, gen: int) -> dict: child = copy.deepcopy(genome) child = self._mutate_params(child, gen) if self.rng.random() < self.cfg.p_struct: child = self._mutate_structure(child, gen) return child def _mutate_params(self, genome, gen): cfg = self.cfg for gid, gene in genome.items(): if not gene.get("enabled", True): continue if self.rng.random() >= cfg.p_rate: continue # 新生基因保护期内降低扰动强度 age = gen - gene.get("birth_gen", 0) protect = 0.3 if age <= 2 else 1.0 if self.rng.random() < cfg.reset_ratio: gene["value"] = float(self.rng.uniform(cfg.w_min, cfg.w_max)) else: sigma = (cfg.sigma_base + cfg.sigma_rel * abs(gene["value"])) * protect gene["value"] += float(self.rng.normal(0.0, sigma)) gene["value"] = float(np.clip(gene["value"], cfg.w_min, cfg.w_max)) return genome结构突变部分稍微长一点,核心是把"合法性"守在前面:
def _mutate_structure(self, genome, gen): cfg = self.cfg r = self.rng.random() a, b, c = cfg.struct_split if r < a: return self._add_connection(genome, gen) elif r < a + b: return self._add_node(genome, gen) else: return self._prune(genome, gen) def _add_connection(self, genome, gen): disabled = [gid for gid, g in genome.items() if not g.get("enabled", True)] if not disabled: return genome gid = disabled[int(self.rng.integers(len(disabled)))] genome[gid]["enabled"] = True genome[gid]["value"] = float(self.rng.normal(0.0, 0.05)) genome[gid]["birth_gen"] = gen return genome_add_node和_prune逻辑类似,关键是在操作前检查候选集是否为空,以及在拆分连接时按前面说的方式分配权重。代码里所有随机调用都走self.rng,一处都不例外。
4.3 单元测试与统计校验
突变器这种"每次都产生随机结果"的组件,最容易被跳过测试,但恰恰是它最需要测试。我一般写三类测试。
第一类是频率测试。固定种子,构造一个 1000 个基因的基因组,跑 1000 次突变,统计每个位点被改动的次数,均值应该接近p_rate * 1000。偏差超过三倍标准差就说明选择逻辑有问题。这个测试能抓住"概率判断写反"之类的低级错误。
第二类是边界测试。构造权重接近上下界的基因组,反复突变,确认输出始终落在[w_min, w_max]内。没有 clip 的实现跑几代就会出现 inf,进而整个种群变成 NaN,这种问题在日志里表现得非常隐蔽。
第三类是不变量测试。mutate返回的对象不能和输入共享任何可变对象,测试方法是突变后直接修改子代的权重,断言父代的值不受影响。这个测试能挡住前面提到的"原地修改污染种群"的问题。
还有一类是分布形状校验,非必须但很有用:把 10000 次扰动的增量收集起来,算一下偏度和峰度,确认接近正态。如果实现里不小心用了均匀分布或者忘了乘 sigma,这个测试能立刻发现。
5. 常见问题与排查实录
5.1 早熟收敛:突变看似生效但种群还是一样
现象是适应度曲线在十代左右就基本平了,种群里的个体相互之间的基因差异极小。很多人第一反应是加大突变率,结果只是让曲线抖得更厉害,平台期依旧。
排查要从三个方向入手。第一,检查突变是否真的作用到了有效基因上。我遇到过一种情况,基因组里有一批被禁用的位点,突变率按全部位点计算,看起来改了很多,实际上有效位点的改动数量只有设定值的三分之一。第二,检查选择压力是不是太强。如果每代只保留最好的两个个体繁殖,不管突变率多大,种群多样性都会被压掉,这时候该调的是精英保留比例而不是突变率。第三,检查是不是所有个体都从同一个祖先复制出来的,收敛速度快得不正常。
真正有效的解法通常是组合拳:把突变率从 5% 提到 8%,同时把精英占比从 20% 降到 10%,再加一个随机 immigrants——每代随机注入几个全新初始化的个体。这个组合我用了很多次,基本都能把平台期往后推。
5.2 结构膨胀:网络越来越大但性能不涨
这个问题的典型特征是平均连接数逐代上升,适应度却停滞。原因通常是结构突变只有加法没有减法,或者新结构带来的微小性能增益没有被惩罚机制抵消。
我的处理方式有三条。第一,确保剪枝算子的概率不为零,且剪枝候选集覆盖所有连接而不只是新连接。第二,在适应度上加一个极小的复杂度惩罚项,比如fitness - lambda * n_connections,lambda取 1e-4 量级就够了,目的是让"同样性能下更简单的结构"略占优势,而不是真的去优化规模。第三,给连接数设一个硬上限,超过上限时强制只允许剪枝不允许加连接。
还有一个容易忽略的点是新生基因保护期不能太长。保护期设成 2 代是合理的,设成 10 代就会导致大量低质量基因长期滞留,结构膨胀会很明显。
5.3 复现性问题的排查清单
"同样的种子跑出不同结果"是这类项目里最让人抓狂的问题,因为它的原因往往藏在很不起眼的角落。我整理了一份按排查顺序排列的清单:
| 排查项 | 典型表现 | 处理方式 |
|---|---|---|
| 是否用了全局随机函数 | 换种子结果不变,或者与种子无关 | 全局搜索np.random.rand、random.random,全部替换为注入的 Generator |
| 字典遍历顺序是否依赖插入顺序 | Python 3.7+ 下通常稳定,但合并基因组时会变 | 遍历前对 key 排序,或用有序容器 |
| 多进程是否共享随机源 | 进程数变化时结果变化 | 每个 worker 按主种子 + worker_id派生独立种子 |
| 浮点求和顺序是否变化 | 差异在小数点后很多位 | 固定求和顺序,或接受微小差异并设置容差 |
| 集合(set)迭代是否参与逻辑 | 结果时好时坏 | 集合只用于成员判断,不用于遍历决策 |
我自己踩过最隐蔽的一次是第二种:基因组合并的时候用了一个 set 去重,然后直接遍历这个 set 做突变,结果每次运行被选中的位点顺序都不同,累积下来几十代之后两个种子的轨迹就完全分叉了。这个 bug 找了整整两天,最后是靠"同一个种子跑两次,把每代所有个体的权重哈希打出来对比"才定位到的。
5.4 数值失控与 NaN 扩散
权重量级爆掉这件事在神经进化里比在梯度下降里更常见,因为没有梯度信号自动帮你约束。表现是跑着跑着适应度整体变成 NaN,或者某个个体的输出永远是 0 或 1。
防线要建在三个位置。突变出口处做 clip,这是最直接的。解码阶段对输入做归一化,避免输入量级本身过大。适应度评估时加一个检查,遇到 NaN 或 inf 直接判为最差适应度,而不是让它参与后续的比较和排序——NaN 参与排序时的行为是未定义的,会污染整个种群。
还有一个隐藏问题是精英保留。如果最好的那个个体恰好是数值异常的,它会被一直保留下来在当前代反复复制,此时种群会快速塌陷。所以精英筛选时也要加数值合法性检查,不能只看适应度数值大小。
5.5 突变算子的组合顺序也有讲究
最后分享一个细节:参数突变和结构突变的执行顺序会影响结果。我现在的顺序是先做参数突变,再做结构突变。原因是结构突变新增的连接,其权重是单独初始化的,如果先做结构突变再做参数突变,新连接的初始小权重会立刻被参数突变改动,失去了"温和接入"的意义。
这个顺序差异在单次突变上看不出来,但跑上几十代,两种顺序得到的结构分布会有明显差别。我做对比实验时统计过,先参数后结构的版本,最终种群的平均连接数比反过来低约 15%,适应度略高一点。样本量不大,不敢说普适,但至少说明这个顺序值得当成一个显式决策来对待,而不是随手写的。
跑完这几轮之后我的体会是,突变器这个部件的开发难点从来不在算法本身,而在于你必须有办法观察它、验证它、复现它。一个能跑出结果的突变器和一个看起来能跑但其实在随机漫步的突变器,在适应度曲线上可能要几十代才能区分开,而如果你手里有前面说的那几类统计测试和复现清单,这个区分在写代码的当天就能做出来。