ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用遗传算法训练神经网络玩贪吃蛇:非梯度进化策略实战

用遗传算法训练神经网络玩贪吃蛇:非梯度进化策略实战 简介使用遗传算法与深度学习训练贪吃蛇AI的完整项目面向人工智能学习者、游戏AI开发者和对进化计算感兴趣的读者解决如何让智能体在动态游戏中自动学习最优策略的问题。压缩包共14个文件主体为7个Python脚本分别实现神经网络结构、遗传算法优化、游戏主逻辑及24维状态输入处理另有2个GIF演示动画展示第5代与第23代蛇的不同表现并包含README说明、依赖清单、训练数据pickle等辅助文件整个包约15MB。目前已有637人学习。通过研读源码读者可掌握将遗传算法用于神经网络权重寻优的流程理解选择、交叉、突变如何驱动策略进化同时可运行项目观察AI跨代能力提升。该项目还附带了可复用的训练脚本与环境定义适合作为强化学习、进化计算课程设计或毕业设计参考也能迁移至机器人控制、自动驾驶决策等相近任务。1. 为什么用遗传算法训一条蛇而不是直接上强化学习玩蛇游戏Snake看起来规则简单吃掉食物、别撞墙、别咬到自己。但真让程序学会玩难点全在“稀疏奖励”上——蛇吃到一个食物才算得 1 分在此之前它可能走几百步都在白忙。用深度强化学习DQN、PPO去训得分信号太稀回报延迟太长训练曲线经常掉进“永远原地转圈”的局部最优。把遗传算法和深度学习搭在一起是另一条被低估的路不依赖梯度反向传播只靠“试错 择优”就能把策略网络的权重进化出来。遗传算法负责全局搜索一个种群里有几十条不同权重初始化的蛇每一条自己玩一局按得分和存活步数打分然后让“表现好的蛇”的权重参数通过交叉、变异生成下一代。深度学习负责表示策略用一个小型神经网络把蛇头周围的障碍物、食物的方向编码成输入输出“上、下、左、右”的动作概率。这套组合特别适合两头不靠的场景——既没有海量标注数据又不想花几天调强化学习的奖励函数。适合读这篇文章的人想用遗传算法做控制策略的开发者、做 AI 大作业的学生、以及想理解“进化策略 神经网络”这类非梯度训练的工程师。下面按我自己的实现路径来讲从环境搭建到参数调优写到可以直接复现的程度。2. 搭贪吃蛇环境状态编码、动作空间和奖励函数设计2.1 状态编码把“看得到”的棋盘变成神经网络的输入常见做法是不把整个棋盘图像喂给网络而是用一组“危险信号 方向信号”的低维向量我一般用 11 个连续值这种紧凑方案。这组特征用“蛇头前方视角”来定义蛇头能往前、往左、往右看三个方向的目标信息。def get_state(snake, food, board_size): head snake[0] # 计算蛇头下一步可能撞到的三种方向 # 方向: 0上, 1右, 2下, 3左 dir_map {0: (0, -1), 1: (1, 0), 2: (0, 1), 3: (-1, 0)} danger [0.0] * 4 # 四个方向是否有障碍 for d, (dx, dy) in dir_map.items(): nx, ny head[0] dx, head[1] dy if nx 0 or nx board_size or ny 0 or ny board_size: danger[d] 1.0 elif (nx, ny) in snake[:-1]: danger[d] 1.0 # 食物方向: 在X/Y轴上的相对方位 food_dir_x 0.0 food_dir_y 0.0 if food[0] head[0]: food_dir_x 1.0 elif food[0] head[0]: food_dir_x -1.0 if food[1] head[1]: food_dir_y 1.0 elif food[1] head[1]: food_dir_y -1.0 return np.array(danger [food_dir_x, food_dir_y], dtypenp.float32)代码逻辑比较简单danger表示蛇头往四个方向走一步是否会撞到墙或自己的身体撞到为 1否则为 0。food_dir_x和food_dir_y给出食物相对蛇头的方位。输入一共 6 维去掉了那种“四个方向是否有危险”的冗余编码。实际训练中我还测试过加“尾巴方向”和“当前方向”的特征但收益很小反而让收敛变慢。单步状态用 6 维足够贪吃蛇的关键决策只看“下一步会不会死”和“食物在哪个方向”。2.2 奖励设计得分是稀疏的需要给 AI 一个“方向感”遗传算法不需要奖励函数可微但需要奖励函数能拉开差距——如果所有蛇得分都差不多选择压力就不够。我用的奖励公式def compute_fitness(steps, score): # 得分为主步数作为引导防止蛇原地绕圈 return score * 100 steps参数说明score是吃到食物的次数乘以 100 是为了放大“吃食物”的目标权重。steps是蛇存活的总步数加入它是因为遗传算法早期几乎吃不到食物如果只看得分所有个体适应度都是 0无法区分优劣。但纯加步数又会让蛇养成“绕圈存活”的惰性所以步数权重只有 1一旦吃到食物得分会立刻拉开差距。另外要给“蛇撞到自己死了”一个小的负反馈吗我试过很多次发现不需要。因为适应度天然包含“活得久”撞死了步数自然少存活压力已经从steps维度体现了再加额外惩罚会让数值尺度更难调。2.3 跑一个随机策略基线先确认环境能转起来遗传算法的迭代周期长环境 bug 藏得越深越难排。所以先写一个随机策略跑 100 局统计平均步数和平均得分def evaluate_random(policy_fn, episodes100): total_steps 0 total_score 0 for _ in range(episodes): state, done env.reset() steps 0 score 0 while not done: action policy_fn(state) # 随机动作 state, reward, done, info env.step(action) steps 1 score info[score] total_steps steps total_score score return total_steps / episodes, total_score / episodes随机策略的参考值10x10 棋盘上平均步数大约 20~30 步平均得分为 0。如果随机策略就能跑到 100 步大概率是“每步不会死”的判断逻辑有漏洞比如蛇头碰到身体的位置计算错误。基线确认完后续训练一旦超过了它就是算法在起作用而不是环境漏了边界条件。3. 策略网络用 PyTorch 把“眼睛”映射到“方向键”3.1 网络结构全连接就够了先别上 CNN贪吃蛇的状态只有 6 维不涉及图像空间结构不需要卷积。一个两层全连接网络就能拟合出“危险感知 → 动作选择”的映射。网络结构我用的是6 - 128 - 64 - 4输出层不带 Softmax让遗传算法直接进化 logits动作选择时用 argmax 就行。import torch import torch.nn as nn class SnakePolicy(nn.Module): def __init__(self, input_dim6, hidden1128, hidden264, output_dim4): super().__init__() self.fc1 nn.Linear(input_dim, hidden1) self.fc2 nn.Linear(hidden1, hidden2) self.fc3 nn.Linear(hidden2, output_dim) def forward(self, x): x torch.relu(self.fc1(x)) x torch.relu(self.fc2(x)) return self.fc3(x) # 输出4个方向的原始打分网络本身没有秘密关键是后续遗传算法怎么改动这些权重。需要说明的是激活函数不建议用 tanh 或 sigmoidReLU 能让早期权重初始化时更容易产生“部分神经元死亡”的效果反而有助于种群多样性——不同的个体激活不同的神经元策略就分化成不同的行为模式。3.2 前向推理和动作选择训练完成后推理逻辑固定为状态转 Tensor → 网络前向 → 取最大 logits 对应的动作。但如果只按最大概率走蛇会卡在“循环重复几个动作”的模式里。推理阶段需要一个 epsilon 随机让蛇偶尔探索一下比纯 greedy 策略得分高。def get_action(policy, state, epsilon0.1): if random.random() epsilon: return random.randint(0, 3) state_tensor torch.tensor(state, dtypetorch.float32).unsqueeze(0) with torch.no_grad(): logits policy(state_tensor) return torch.argmax(logits).item()epsilon 值 0.1 是在 10x10 棋盘上反复试出来的太大了蛇会主动撞墙太小了遇到新布局不会转向。这个参数在训练时和推理时可以不一样推理阶段通常调低到 0.05。3.3 为什么这个网络不能直接拿梯度训练常规深度学习靠“损失函数 → 反向传播 → 更新权重”但这条蛇的任务找不到一个可微的损失函数。你没法把“吃不到食物”写成网络的损失表达式——吃到食物是环境动态交互的结果网络输出一个动作改变蛇的轨迹而轨迹与得分之间是离散、延迟、非平滑的关系。反向传播在这里失效这就是我们引入遗传算法的根本原因遗传算法只需要适应度函数给每个个体打分不要求打分函数可微。4. 遗传算法核心种群、适应度和交叉熵方法CEM4.1 遗传算法四件套编码、选择、交叉、变异传统遗传算法把参数编码成二进制串做单点交叉、位翻转变异。但神经网络权重是连续值二进制编码会破坏数值的局部连续性——权重 0.1 变成二进制再交叉可能一下跳到 100 附近训练完全散架。我自己的做法用实数编码交叉取两个精英个体的参数加权平均变异加高斯噪声。每代种群大小我设为 64精英数量取 8前 12.5%。选择压力太小比如精英 20 个会让种群多样性过高进化缓慢选择压力太大精英 2 个会过早收敛所有个体都变成一个模子刻出来的。def crossover(parent_a_params, parent_b_params, alpha0.5): child_params [] for pa, pb in zip(parent_a_params, parent_b_params): child alpha * pa (1 - alpha) * pb child_params.append(child) return child_params def mutate(params, noise_std0.1): mutated [] for p in params: noise torch.randn_like(p) * noise_std mutated.append(p noise) return mutated交叉里的alpha0.5是两个精英的等权平均alpha 越靠近 1 越像第一个父代。高斯噪声的标准差是变异强度noise_std0.1在我的训练里是平衡点——太大导致精英基因被冲散太小导致新个体和父代几乎一样、搜索停滞。4.2 用 CEM 更新参数分布让“下一代”出生在更好的地方按上面的交叉变异方式做 50 代之后我发现瓶颈交叉和变异是逐层独立的但神经网络各层之间有耦合关系某一层参数微调 0.1下一层可能完全变了。于是换成了 CEMCross-Entropy Method交叉熵方法它不再显式做“父母交叉”而是维护一个参数分布均值和标准差每代从分布里采样一组权重用精英个体更新分布均值。CEM 的更新公式为def update_distribution(elite_params_list, mean, std, lr0.1): # 精英个体参数求平均作为新均值方向 new_mean_list [] for layer_idx in range(len(mean)): layer_values torch.stack([elite[layer_idx] for elite in elite_params_list]) new_mean layer_values.mean(dim0) new_mean_list.append(new_mean) # 用学习率平滑更新防止分布震荡 updated_mean [lr * nm (1 - lr) * old_m for nm, old_m in zip(new_mean_list, mean)] updated_std [s * 0.99 for s in std] # 标准差缓慢衰减 return updated_mean, updated_std这里lr0.1控制均值更新速度太大会让种群快速坍塌到局部最优太小则收敛太慢。标准差每个逐代乘 0.99相当于变异率的指数衰减前 50 代探索范围大后 50 代逐步聚焦到高适应度区域。注意mean是一个列表里面每个元素是与网络各层权重形状相同的 Tensor。4.3 完整训练循环从随机权重到能绕圈的蛇把上面所有部分拼起来核心训练循环如下def train_cem(pop_size64, elite_size8, generations200, noise_std0.5): policy SnakePolicy() # 初始化参数分布: 均值为0, 标准差为0.5 mean [torch.zeros_like(p) for p in policy.parameters()] std [torch.full_like(p, noise_std) for p in policy.parameters()] for gen in range(generations): population [] for _ in range(pop_size): # 从分布采样一组网络参数 sample_params [] for m, s in zip(mean, std): sample_params.append(torch.normal(m, s)) # 载入参数并评估 candidate SnakePolicy() with torch.no_grad(): for param, new_param in zip(candidate.parameters(), sample_params): param.copy_(new_param) fitness, steps evaluate(candidate) population.append((fitness, sample_params)) # 选出适应度最高的精英 population.sort(keylambda x: x[0], reverseTrue) elites [params for _, params in population[:elite_size]] # CEM 更新 mean, std update_distribution(elites, mean, std, lr0.1) gen_best population[0][0] print(fGeneration {gen}: best_fitness{gen_best:.1f}, avg_steps{steps:.1f})一个容易踩的坑torch.normal(m, s)在s0.5初始时会采样出绝对值超过 2 的权重网络输出可能趋于饱和。我在初始化时加了限制把采样权重裁剪到 [-3, 3] 区间有效防止早期种群出现“疯蛇”——永远朝着一个方向冲到死。4.4 训练中容易撞上的三个坑第一个坑是精英适应度相同导致选择失效。10x10 棋盘前 30 代很多个体得分为 0只能靠步数区分但步数差异可能只有几步。这时候可以把步数奖励放大或者先在小棋盘如 7x7上预训练 50 代让小蛇更容易吃到食物产生有区分度的适应度信号。第二个坑是**“绕圈”行为**。蛇吃到 3~4 个食物后身体变长活动空间缩小经常出现原地转圈直到撞到自己。这说明策略网络只学到了“避免当前步撞墙”没有学到“避免围住自己”。缓解办法是增加奖励中的步数权重会让蛇更怕死、更保守但会减少探索。我更常用的是惩罚重复状态如果一个状态之前出现过适应度扣分驱使蛇尝试新路径。第三个坑是标准差衰减太快。如果std每代乘以 0.930 代后标准差趋近于零种群失去探索能力。用 0.99 或者 0.995 更稳妥让突变一直保有微小概率。5. 验证 AI 学到的策略观察、打分和参数微调5.1 训练完成后的验证指标不能只看“吃到了几个食物”遗传算法容易过拟合到训练时的初始蛇身位置和食物生成序列。我每次评估用 50 个不同随机种子统计 50 局的平均得分、中位得分、最大得分和“死亡原因分布”。光看平均得分会被少数超长局拉高中位数更能反映稳定水平。指标随机策略CEM 训练 100 代CEM 训练 200 代平均得分0.02.35.1中位得分0.02.05.0最大得分169平均步数28210560这张表的取值来自一次典型训练棋盘 10x10种群 64训练 200 代。如果中位得分能达到 5说明蛇至少能吃完一轮小棋盘的一半食物这时候策略已经有实际的避障和路径规划能力。5.2 观察 AI 行为时重点看三个模式把训练好的模型渲染出来观察它怎么玩重点看三个模式。第一是“贴墙走”很多 CEM 训练出来的蛇学会了贴墙绕圈因为贴墙一头是墙、另一头是自己的尾巴危险方向从四个减到两个决策难度降低这种策略虽然稳定但容易把自己困死。第二是“回头吃尾巴根”食物出现在身体附近时蛇会绕一个大圈从尾巴方向接近说明网络学到了“尾巴会散开”的隐式知识。第三是“赶路模式”食物在远端时蛇走直线、不绕弯说明食物方向特征被有效利用了。5.3 卡在局部最优时调什么参数如果你训练完发现蛇只会左转卡在 2~3 分上不去优先按顺序尝试三项调整。第一调大初始noise_std到 1.0让早期个体行为更激进有机会碰出不同的策略。第二调高精英数量从 8 到 12保留更多样化的基因。第三修改适应度函数把score * 100改成score * 200同时把步数权重从 1 降到 0.5牺牲一部分存活能力换取“主动找食物”的行为压力。另一个有效且简单的做法是“分阶段训练”先只奖励步数训 50 代让蛇学会不撞墙再切换到“步数 得分”混合奖励训 100 代最后切到纯得分奖励训 50 代。渐进式改变目标函数能让种群在保持存活能力的基础上去优化得分比单一大而全的奖励函数更容易跑出高分。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进