拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

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

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

去年我在调 NEAT 算法实验的时候,发现一个特别容易让人上头的场景:用 Python 从零写一个贪吃蛇环境,再让 AI 通过 NEAT 算法自己进化出玩游戏的策略。整个过程不需要 GPU、不需要 PyTorch 或 TensorFlow,一台普通 CPU 就能跑,而且你能亲眼看到一坨只会乱爬的神经网络,一代一代进化出会预判、会绕路、会贴着墙游走的策略。这篇文章就是把我从环境搭建、NEAT 原理理解、输入输出设计,到踩坑调参的完整过程记录下来。如果你想入门遗传算法和神经网络进化,或者单纯想看看 AI 是怎么"从零学会打游戏"的,这篇应该能给你一份可以直接抄作业的参考。

1. NEAT 算法到底在进化什么:三分钟理解核心机制

1.1 基因不是"层"而是"点线连接图"

NEAT(NeuroEvolution of Augmenting Topologies)最核心的思想,是它不预设神经网络的层数和宽度。传统神经网络要先定结构再调权重,NEAT 反过来:它从最简网络出发,把网络结构和权重一起当作进化对象。

在 NEAT 里,一个基因组(Genome)由两类基因组成:节点基因和连接基因。节点基因就是网络里的神经元,连接基因就是神经元之间的边,每条边带一个权重。最初始的基因组通常只有输入节点、输出节点和几条稀疏的连接,跑起来效果一塌糊涂。但随着变异,它会随机添加新节点、新连接,网络拓扑越来越复杂。

这里有个关键概念叫历史标记(innovation number)。每出现一条全新结构的连接,就给它一个全局唯一的编号。这个编号最大的作用,是让两个基因组在交叉时能精准对齐——相同编号的连接基因是一对,直接交叉。这样就不会出现"两条结构完全不同的网络在瞎融合"的混乱场面。

1.2 创新的保护:物种形成的必要性

一开始我以为 NEAT 就是个"遗传算法 + 神经网络",直到我观察到它频繁出现"结构创新被淘汰"的问题,才意识到物种形成机制有多重要。

你想,一个网络好不容易变异出新的中间节点,初期这个节点的权重是随机的,大概率会让整个网络表现变差。如果直接让这个"变差"的个体去和所有个体公平竞争,它很快就被淘汰了,那网络结构就永远无法变复杂。

NEAT 的解决方法是物种形成(Speciation):根据基因组结构的相似度,把种群分成多个物种。结构差异足够大的个体被分到不同的物种里,只在物种内部竞争。这样那些"当前表现不好但结构新颖"的个体,能在自己的小圈子里存活下来,等后代把权重优化好之后,很可能突然反超之前的优势物种。

相似度怎么算?NEAT 用兼容性距离公式:

compatibility = (c1 * E) / N + (c2 * D) / N + c3 * W

E 是两个基因组的连接基因匹配不上的数量,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 我最终采用的 14+3 输入输出方案

我用的是 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 pygame

pygame 用来可视化回放,训练本身不需要它。整个项目拆成三个文件:

文件职责
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 inputs

train.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(width=20, height=20) 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_prefix="neat-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 之类的真实场景里是通用的。如果你也在玩这类进化算法的项目,多花点时间在输入设计和适应度函数上,回报绝对比堆超参数要大得多。

返回列表