ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

NEAT算法实战:用遗传算法从零训练贪吃蛇AI

NEAT算法实战:用遗传算法从零训练贪吃蛇AI 去年我在调 NEAT 算法实验的时候发现一个特别容易让人上头的场景用 Python 从零写一个贪吃蛇环境再让 AI 通过 NEAT 算法自己进化出玩游戏的策略。整个过程不需要 GPU、不需要 PyTorch 或 TensorFlow一台普通 CPU 就能跑而且你能亲眼看到一坨只会乱爬的神经网络一代一代进化出会预判、会绕路、会贴着墙游走的策略。这篇文章就是把我从环境搭建、NEAT 原理理解、输入输出设计到踩坑调参的完整过程记录下来。如果你想入门遗传算法和神经网络进化或者单纯想看看 AI 是怎么从零学会打游戏的这篇应该能给你一份可以直接抄作业的参考。1. NEAT 算法到底在进化什么三分钟理解核心机制1.1 基因不是层而是点线连接图NEATNeuroEvolution of Augmenting Topologies最核心的思想是它不预设神经网络的层数和宽度。传统神经网络要先定结构再调权重NEAT 反过来它从最简网络出发把网络结构和权重一起当作进化对象。在 NEAT 里一个基因组Genome由两类基因组成节点基因和连接基因。节点基因就是网络里的神经元连接基因就是神经元之间的边每条边带一个权重。最初始的基因组通常只有输入节点、输出节点和几条稀疏的连接跑起来效果一塌糊涂。但随着变异它会随机添加新节点、新连接网络拓扑越来越复杂。这里有个关键概念叫历史标记innovation number。每出现一条全新结构的连接就给它一个全局唯一的编号。这个编号最大的作用是让两个基因组在交叉时能精准对齐——相同编号的连接基因是一对直接交叉。这样就不会出现两条结构完全不同的网络在瞎融合的混乱场面。1.2 创新的保护物种形成的必要性一开始我以为 NEAT 就是个遗传算法 神经网络直到我观察到它频繁出现结构创新被淘汰的问题才意识到物种形成机制有多重要。你想一个网络好不容易变异出新的中间节点初期这个节点的权重是随机的大概率会让整个网络表现变差。如果直接让这个变差的个体去和所有个体公平竞争它很快就被淘汰了那网络结构就永远无法变复杂。NEAT 的解决方法是物种形成Speciation根据基因组结构的相似度把种群分成多个物种。结构差异足够大的个体被分到不同的物种里只在物种内部竞争。这样那些当前表现不好但结构新颖的个体能在自己的小圈子里存活下来等后代把权重优化好之后很可能突然反超之前的优势物种。相似度怎么算NEAT 用兼容性距离公式compatibility (c1 * E) / N (c2 * D) / N c3 * WE 是两个基因组的连接基因匹配不上的数量D 是节点基因的差异数量W 是匹配连接的平均权重差N 是基因总数。简单说结构差得越多这个值越大。超过阈值就分到不同物种。1.3 为什么它特别适合贪吃蛇这种小状态空间问题贪吃蛇这游戏地图是离散网格蛇的长度会变规则简单但需要一定的实时决策能力。用深度强化学习来做当然可以但中间要处理奖励稀疏、训练不稳定、超参数多这一堆问题。NEAT 在这个场景有几个天然优势。一是状态空间不大输入维度一般十几维就够网络不需要很深NEAT 几代就能搜出像样的结构。二是奖励稀疏的问题对进化算法相对友好因为种群本身就是一堆随机策略在并行试错不像 DQN 那样靠时序差分一点点回传。三是可解释性强训练完可以把最优网络打印出来看它到底长了多少节点甚至手工分析连接权重的含义。不过也要说实话NEAT 在图像输入、语言这类高维连续状态上效率很低那更适合深度学习。贪吃蛇这种状态编码清晰的小游戏反而是 NEAT 最容易出效果的舞台。2. 游戏环境与输入输出设计AI 的眼睛和手2.1 输入方案的几种主流选择训练环境用 Python 自己写一个贪吃蛇并不难一个二维网格、一条蛇、一个食物每次移动一格。真正决定 AI 能不能学会的是输入向量怎么设计。我对比过三种方案。第一种是全图网格输入直接把整张地图每个格子编码成数组食物是 1、蛇身是 -1、空地是 0。这种方式信息最全但输入维度随地图变大急剧膨胀网络要自己学会从二维空间里抽取特征NEAT 的搜索效率会明显下降。第二种是局部视野输入只取蛇头周围 3x3 或者 5x5 的格子状态。优点是输入维度小缺点是蛇看不见远处的情况经常走到看起来安全但再走两步就是死路的位置。第三种是射线传感器输入以蛇头为中心向 8 个方向发射视线记录每个方向到障碍物墙、蛇身的最近距离。这相当于给蛇装了一圈雷达计算量小信息密度高也是我最后选用的方案。2.2 我最终采用的 143 输入输出方案我用的是 14 维输入、3 维输出。输入向量组成如下编号含义归一化方式0-78 个方向的障碍物距离上、右上、右、右下、下、左下、左、左上距离 / 地图边长映射到 [0,1]8-9食物相对蛇头的位置 (dx, dy)分别除以地图宽度和高度10-13蛇头当前移动方向的 one-hot 编码上/右/下/左0 或 1输出是 3 个动作直走、左转、右转。注意这里是相对方向而不是绝对方向这一点很重要。如果用上下左右四个绝对方向做输出就可能出现一个回合内先向上、下一步又输出向下的情况蛇头直接原路返回撞到自己身上这种无效动作白白浪费步数还会让初始种群的学习信号变得非常差。动作和方向的转换逻辑是蛇头当前方向 动作指令 新方向。比如当前方向是右动作是左转新方向就是上。这样不管蛇怎么转向它永远不会走回头路环境本身就把一部分无效动作过滤掉了。几个关键实现细节障碍距离的计算我用的是逐步向外扫描的方式从蛇头位置出发沿着某个方向一格一格检查直到撞墙或者撞到蛇身记录步数。食物相对位置用 (dx, dy) 而不是 one-hot 方向实测收敛更快。因为连续值能直接告诉网络食物在右上方大概多远网络拟合起来更平滑。如果把蛇头当前方向也编码进去网络才能正确理解左转右转相对于谁在转。我第一次没加这 4 个输入训练了很久蛇都像无头苍蝇后来才意识到这个细节。2.3 步进机制与烦躁度掐表贪吃蛇环境的另一个关键设计是步进控制。最原始的写法是蛇一直移动直到撞墙或撞到自己才结束但这会导致一个问题蛇学会了在原地绕圈一圈一圈转个不停每一步都安全却永远吃不到食物。如果不对这种转圈行为做惩罚适应度评估会被拖得非常慢而且这种个体还容易在种群中扩散。我的做法是加一个烦躁度计数器每吃到一次食物计数器清零。每走一步计数器加 1。如果计数器超过 50强制结束本回合并给一个额外的负奖励。这个机制等价于给蛇一个时间压力你必须在有限步数内找到食物不然就判负。实测下来这种超时判定比单纯在适应度函数里减步数惩罚更直接因为它直接剪掉了大量的无效评估时间训练速度快了一大截。3. 适应度函数与 NEAT 参数进化方向的指挥棒3.1 适应度函数最容易踩的两个陷阱适应度函数是整个 NEAT 训练里最重要的部分因为它直接定义了什么叫表现好。我踩过的第一个坑是奖励稀疏到没有梯度。如果适应度只等于吃到的食物数量在初始阶段绝大多数个体一个食物都吃不到适应度全是 0。种群根本没有筛选依据进化就是瞎碰。第二个坑是只奖励吃食物、不惩罚步数。这种情况下学会吃到 3 个食物的个体可能绕了很长的路才吃到。虽然结果一样但绕路的策略会遗传下去后代全变成慢性子效率极低。3.2 分段奖励与步数惩罚的平衡我最后用的适应度公式很简单fitness 食物数量 * 100 - 总步数 * 0.1 - 超时惩罚超时惩罚我设为 100和吃一个食物的收益相同。等价于如果你为了贪图安全绕路吃了 50 步才吃到食物那这个食物的收益基本就被步数惩罚抵消了。这样蛇会倾向于走更高效的路线。这里有个经验步数惩罚系数不要设太大。一开始我设成每步 -0.5结果蛇变得极度怕死宁可原地踏步也不敢探索。后来想明白了步数惩罚的作用只是平衡路径效率不是让蛇惜步如金。0.1 这个量级对 100 的食物奖励来说相当于允许蛇在食物附近绕个五六十步还能接受但绕太多就亏了。适应度评估我采用了每局从随机初始状态开始跑完一整局以最终 fitness 作为该基因组的分数。为了降低随机性前 50 代我跑 3 局取平均分50 代之后因为单局变长、耗时上升改成跑 1 局。如果你在复现时觉得训练速度太慢这个跑局数是对时间影响最大的参数可以优先调整。3.3 贪吃蛇场景下的超参数速查表NEAT 的超参数全在配置文件里。我调了几轮之后稳定用这套配置[NEAT] fitness_criterion max fitness_threshold 5000 pop_size 200 reset_on_extinction True [DefaultGenome] activation_default tanh activation_options tanh sigmoid aggregation_default sum bias_init_mean 0.0 bias_init_stdev 1.0 bias_mutate_power 0.5 bias_mutate_rate 0.7 bias_replace_rate 0.1 weight_init_mean 0.0 weight_init_stdev 1.0 weight_mutate_power 0.5 weight_mutate_rate 0.6 weight_replace_rate 0.1 mutate_add_node 0.1 mutate_add_conn 0.3 mutate_delete_node 0.0 mutate_delete_conn 0.0 crossover_rate 0.7 [DefaultSpeciesSet] compatibility_threshold 3.0 [DefaultStagnation] species_fitness_func max max_stagnation 15 species_elitism 2几个关键参数说一下pop_size 200贪吃蛇状态空间小200 个个体足够再多训练时间会翻倍收益没那么大。mutate_add_node 0.1增加节点的概率控制在 0.1 左右。太高会让种群结构疯狂膨胀全是随机连接但权重都没优化好太低则网络结构太简单表达不了复杂的避障策略。compatibility_threshold 3.0这个值决定物种划分的粗细。调小了物种多、各物种个体少、创新容易被保护调大了物种少、竞争激烈。先用默认值再看训练曲线调整。注意neat-python 的配置项版本之间有细微差别如果你用的版本不同跑起来报 Missing config option 之类的错把对应的 key 补上就行参数含义不变。4. 训练实战从乱爬到会走位4.1 第 1-20 代漫无目的的探索第一次跑训练的时候我在终端开着 NEAT 的 StdOutReporter 输出每代信息一闪而过。前十几代的数据基本是最高适应度: 0.1这种惨状。整个种群没有一个能吃到食物的个体全是出生几秒就撞墙或撞自己死掉。这个阶段看什么看平均适应度有没有缓慢上升。虽然没人吃到食物但有些个体的行为方式开始偏向朝食物方向转弯虽然还走不到但至少比完全随机原地转圈强一点。NEAT 的初始网络输入到输出几乎是随机映射所以这段缓慢爬坡是正常的不用慌。4.2 第 30-60 代吃到第一个食物的里程碑大概在第 32 代左右我第一次看到最高适应度: 86.4这种数字那一刻确实挺兴奋的。这意味着某个个体成功吃到了第一个食物而且是在 100 多步内吃到的。这个阶段有个有趣的现象进化曲线是阶梯式的。连续十几代没有提升突然某一代冒出一个结构变异后的个体分数跳涨一截。这就是 NEAT 的特色——它不只是调权重还会长出新的网络结构结构创新带来的能力提升是跳跃式的不是平滑的。所以训练脑的时候得有耐心看到平台期不要急着停说不定下代就跳上去了。4.3 第 80 代以后策略成型与稳定性到第 80 代左右冠军个体已经能稳定吃到 6-8 个食物。回放它的行为能看到清晰的策略模式食物在正前方且前方通畅时果断直走。食物在侧方且侧向有空间时提前转弯而不是临近了才转。吃到食物后如果蛇身变长堵住了某条路它会花几步调整方向从另一侧绕过去。这个阶段的适应度大约在 400-700 徘徊。但到 8 个食物以后蛇身长度超过 15 格回旋空间变小很多冠军个体死在把自己围死的走法上。想往更高分冲需要更强的全局规划能力NEAT 在纯反应式策略下会比较吃力这时候我的建议是调整输入设计比如加一个蛇身占据面积比例的输入或者把视线传感器改成局部网格视野给网络更多空间感知能力。5. 训练过程中的常见坑与定位方法5.1 坑一适应度一直为 0种群毫无进展如果你跑了几十代最好适应度仍然是 0大概率不是 NEAT 的问题而是环境或者输入设计的问题。先检查三件事输入向量里的障碍距离有没有归一化如果直接给原始像素距离比如 200、300 这种大数字经过 tanh 直接饱和输入变化对网络输出的影响几乎为零等于没给眼睛。食物位置方向有没有编码错dx、dy 符号方向反了会让 AI 一直朝远离食物的方向走。动作映射是否有效如果直走和左转在环境里映射成了同一个方向那 AI 不管怎么进化都只能走直线。定位方法很简单手动固定一组随机权重把网络输出打印出来人肉判断输出和输入有没有对应关系。如果输入变化时输出完全不动那一定是输入设计和网络激活的问题。5.2 坑二学会了转圈但不吃食物这是没有烦躁度惩罚时最经典的现象。在我加上超时中断机制之前训练出来的冠军蛇有个特别明显的毛病它会在一个安全圈子里不停转圈活得比谁都久但就是不往食物方向走。从适应度的角度看它还挺高的因为不吃食物就没有负奖励每步扣的那点分对一个能活几千步的个体来说完全能承受。解决方式就是前面说的超时中断。把绕圈变成会被强制判负的行为转圈策略一下子就失去了生存空间。类似的思路在真实强化学习里也很常见——给智能体一个 time horizon别让它无限期地磨洋工。5.3 坑三网络拓扑猛涨但分数不涨运行到后期你可能会发现每代平均节点数从 12 涨到 80但最高适应度纹丝不动。这是因为 mutate_add_node 率偏高网络疯狂长结构但权重没跟上优化拓扑复杂度高到网络已经消化不良。这种情况我会把 mutate_add_node 从 0.1 降到 0.03同时把 weight_mutate_power 调大一点到 0.8让已有的连接权重有更大步长去搜索。还有一种有效操作是调整 compatibility_threshold比如从 3.0 调低到 2.0让物种划分变细给更多拓扑风格各异的个体独立演化的空间。5.4 其他容易被忽略的小细节一个是存档。NEAT 训练可能一跑就是半小时起步不加 checkpoint 一旦中途崩了全得重来。neat-python 自带的 Checkpointer 很省事每 10 代存一个文件跑了 190 代崩溃也能从 180 代的存档恢复。另一个是可视化。我强烈建议把每一代的冠军个体行为录屏或者实时显示不要只盯着数字看。数字只能告诉你适应度涨没涨但视频能告诉你它是什么策略涨的。有一次我发现适应度涨得飞快一录屏才知道是因为蛇头总是直奔地图右下角——那里食物刷新概率高这种作弊式策略如果不看行为很难发现。6. 完整代码框架与运行指南6.1 项目结构我用的环境是 Python 3.10 neat-python 库安装只需要一条命令pip install neat-python numpy pygamepygame 用来可视化回放训练本身不需要它。整个项目拆成三个文件文件职责snake_env.py贪吃蛇环境负责状态更新、碰撞检测、输入向量生成train.pyNEAT 训练主入口定义适应度函数运行进化play.py加载最优基因组用 pygame 回放 AI 游戏过程6.2 核心代码思路snake_env.py 里最关键的就是 get_inputs 函数生成 14 维输入向量def get_inputs(self): inputs [] # 8 个方向的障碍距离 for d in range(8): distance self._ray_cast(self.head, d) inputs.append(distance / self.width) # 食物相对坐标 dx self.food.x - self.head.x dy self.food.y - self.head.y inputs.append(dx / self.width) inputs.append(dy / self.height) # 当前方向 one-hot for d in range(4): inputs.append(1.0 if self.direction d else 0.0) return inputstrain.py 里最核心的是适应度函数和主循环def eval_genome(genome, config): net neat.nn.FeedForwardNetwork.create(genome, config) total_fitness 0.0 trials 3 if generation 50 else 1 for _ in range(trials): env SnakeEnv(width20, height20) done False fitness 0.0 steps_since_food 0 while not done: obs env.get_inputs() output net.activate(obs) action int(np.argmax(output)) fitness, done, info env.step(action) steps_since_food 1 if env.ate_food: steps_since_food 0 if steps_since_food 50: fitness - 100 break total_fitness fitness return total_fitness / trials注意这里我没有把每步 -0.1 显式写出来而是直接放在 env.step 返回的 reward 里累加。环境的 step 函数在每次移动后返回reward 食物数量变化 * 100 - 0.1这样适应度逻辑和环境逻辑分开代码更清晰。主循环代码如下import neat config neat.Config(neat.DefaultGenome, neat.DefaultReproduction, neat.DefaultSpeciesSet, neat.DefaultStagnation, config.txt) pop neat.Population(config) pop.add_reporter(neat.StdOutReporter(True)) stats neat.StatisticsReporter() pop.add_reporter(stats) pop.add_reporter(neat.Checkpointer(10, filename_prefixneat-checkpoint-)) winner pop.run(eval_genome, 300) with open(winner.pkl, wb) as f: pickle.dump(winner, f)训练结束之后用 pickle 加载 winner 基因组在 play.py 里创建同款网络并驱动蛇移动再加上 pygame 绘制网格就能看到 AI 的完整游戏过程。6.3 运行效果与扩展方向实测在 20x20 地图上纯 CPU 单核跑200 个种群的规模下单代大约 3-8 秒前期快后期慢到拿到一个能吃 8 个食物的冠军大约需要 10-15 分钟。作为对比我拿 DQN 试过同款游戏要跑到同样的水平花费的时间多得多而且模型调参更繁琐。如果你想在这个项目上继续扩展我的建议是两条路。一条是加大游戏难度比如地图里添加静态障碍物、让食物定时刷新位置看看 NEAT 能否进化出绕开复杂障碍的策略。另一条是换输入方案做对照实验把视线传感器换成 5x5 局部网格视野对比两种方案在相同参数下的表现差异。这种对照实验做出来非常有意思因为你会发现 NEAT 对不同信息编码方式的敏感度超出预期。再分享一个我自己的体会做这个项目最大的收获不是学会怎么调 NEAT而是建立起一种直觉——当一个问题能编码成低维状态输入时进化算法往往比梯度下降更快、更稳地找到可行解。贪吃蛇只是个玩具但这个思路放到路径规划、策略搜索、游戏 AI bot 之类的真实场景里是通用的。如果你也在玩这类进化算法的项目多花点时间在输入设计和适应度函数上回报绝对比堆超参数要大得多。
返回列表