ARTICLE DETAIL

资讯详情

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

用DQN强化学习实现自动玩贪吃蛇AI的完整实战教程

用DQN强化学习实现自动玩贪吃蛇AI的完整实战教程 简介面向对人工智能游戏开发感兴趣的开发者与学生这份资源完整实现了基于深度学习的遗传算法训练贪吃蛇模型让AI在迷宫中自动寻路、避障并高效觅食。项目将神经网络作为蛇的决策大脑借助遗传算法迭代优化网络权重覆盖环境搭建、模型定义、种群进化与竞技评估等关键环节适合入门深度强化学习和遗传算法实践。压缩包共14个文件以7个Python脚本为核心分别负责颜色配置、神经网络结构、遗传算法逻辑、游戏引擎、输入控制与主流程同时附带2张训练过程GIF用于直观展示进化效果以及README说明、依赖清单requirements.txt、已训练模型存档top_snakes.pickle和开源许可文件整体约13.11MB。当前已有3205人学习参考。读者可运行完整源码观察AI学习过程也可加载模型存档快速验证效果还能依据清晰的文件结构进行二次开发将训练思路迁移到其他游戏中。1. 为什么拿贪吃蛇做AI入门是件很聪明的事贪吃蛇这个项目在AI学习圈里属于“看着简单、做起来五脏俱全”的典型代表。做人工智能大作业的同学或者自学强化学习的初学者很多人的第一个完整项目都是它——不是因为别的而是因为这个游戏把强化学习最核心的要素全占了状态、动作、奖励、环境交互。你不需要搞复杂的物理引擎不需要处理图像识别核心逻辑就是让智能体在一个网格世界里存活、吃食物、避免撞墙撞自己。我用DQNDeep Q-Network来做这个自动玩贪吃蛇的AI也就是把Q-learning和深度神经网络结合。传统Q-learning在状态空间小的时候很管用但贪吃蛇的棋盘虽然不大我用的20x20蛇身的长度可达上百格状态组合数量完全是天文数字靠查表法根本不可能。DQN的思路是用神经网络去近似Q值函数输入是当前状态输出是每个动作的预估回报然后不断用游戏产生的数据去更新这个网络。这套方案对初学者特别友好因为它不需要你手写复杂的特征工程神经网络会自动从原始状态中提取有用的信息。同时它又足够有挑战性训练过程中的各种坑——奖励稀疏、训练不稳定、探索和利用的平衡——都能碰到做完之后对强化学习的理解会深一个层次。我花了大概两个晚上完成了初版后来又花了两天调参优化算下来总共不到一周时间整个项目的完整代码和训练日志都存在GitHub仓库里。适合看这篇文章的人我觉得有三类刚学完Python基础、想做点有意思项目的人工智能导论课程需要交大作业的以及想入门强化学习但不知道从哪里开始的。代码我会给出来但更重要的是把思路讲清楚让你能自己把它改造成别的游戏。2. 整体设计思路从游戏环境到决策大脑2.1 环境搭建游戏不只是画面更是AI的交互接口AI要玩游戏首先得有个它能够感知和操作的环境。很多人写贪吃蛇会直接在一个While循环里写完整套游戏逻辑但做AI就不一样了你得把游戏拆成一个一个的“回合制”接口环境先给你一个状态你根据状态做出一个动作环境根据动作更新状态然后告诉你获得了多少奖励。我用的游戏环境是基于Pygame开发的。Pygame这套库做2D小游戏非常成熟处理键盘事件、画面渲染都很方便。我把它封装成了一个SnakeEnv类核心接口就这么几个reset()重置游戏step(action)执行动作并返回新的状态、奖励和是否结束render()负责画面渲染。一个比较关键的细节是循环节拍的控制。训练的时候不需要渲染画面或者最多每秒渲染几帧来看效果所以我设置了fps参数来控制。训练阶段设为0完全不渲染只跑逻辑测试演示阶段设为10每秒10帧画面不至于闪太快。这样能省下大量的训练时间实测训练10万步纯逻辑模式只需要渲染模式的六分之一时间。2.2 状态表示AI眼里没有画面只有数值AI并不能像人那样看到画面它看到的是数值。怎么把贪吃蛇的当前情况转成一组有意义的数字这直接决定了模型的上限。我试过好几种方案最后留下来的那套效果最稳定。最基础的信息是蛇头和食物的相对位置。我把20x20的网格看成一个坐标系蛇头的坐标是(head_x, head_y)食物的坐标是(food_x, food_y)那就能算出相对偏移量dx和dy。这两个值归一化到[-1, 1]区间作为模型输入的2个特征。第二个信息是蛇头周围的安全情况。我分别检查蛇头上方、下方、左方、右方4个位置看是否能走不撞墙、不撞到自己能设为1不能设为0得到4个布尔特征。第三个信息也很重要就是蛇当前的移动方向。因为贪吃蛇不允许你直接掉头如果你现在正在向右走那“向左”这个动作实际上是非法且无意义的。我用one-hot编码表示当前方向同样4个维度。总结下来状态向量一共10维2维食物相对位置、4维周围安全信息、4维当前方向。这个状态设计不是我凭空想出来的参考了很多开源项目的做法实际跑下来效果确实不错。有人会把整个棋盘格点化成二维数组喂给CNN理论上能提取更多空间特征但计算开销大了不少对于这个小游戏来说有点过犹不及。2.3 DQN工作原理为什么神经网络能学会“趋利避害”在讲具体代码之前得先把DQN的核心机制理清楚不然代码看了也白看。传统Q-learning维护一张Q表记录每个状态动作对的价值。Q值的更新公式是经典的Bellman方程Q(s,a) r γ * max_a Q(s, a)意思是当前状态下采取动作a的价值等于立即获得的奖励r加上未来状态的最高Q值乘以一个折扣系数γ。这个γ一般取0.9到0.99之间用来衡量“未来的收益相对于现在的收益打多少折扣”。DQN做的事情就是用一个深度神经网络Q(s,a;θ)去逼近这个Q函数每次训练时计算目标值 r γ * max_a Q(s, a; θ_target)然后让网络预测值向目标值靠近本质就是监督学习里的回归问题。但直接这么做有一个著名的坑训练不稳定。因为你的训练数据是自己在跟自己玩每一步计算的目标值和预测值都来自同一个网络相当于“左脚踩右脚上天”很容易震荡甚至发散。DQN原论文提出了两个关键技巧来解决这个问题经验回放和目标网络。经验回放简单说就是建立一个缓冲区把每一步游戏的数据(状态、动作、奖励、下一状态、是否结束)存进去训练时随机抽取一小批来更新网络。这样打破了数据之间的时间相关性让训练更稳定。目标网络则是一个更新频率更慢的“影子网络”计算目标值时用它避免目标值跟着当前网络剧烈波动。关于经验池的大小和batch size我试过几个组合最后锁定经验池20000条、batch size 64。经验池太小容易遗忘早期学到的经验太大则更新方向太分散。3. 代码实现从零搭建自动玩贪吃蛇的AI3.1 环境核心代码如何让游戏“可编程”下面我直接给出环境类的核心代码。这个类隔离了游戏逻辑和AI逻辑后面不管是换成别的算法还是换游戏只需要改这个接口就行。import pygame import numpy as np import random class SnakeEnv: def __init__(self, grid_size20, fps0): self.grid_size grid_size self.fps fps if fps 0: pygame.init() self.screen pygame.display.set_mode((grid_size * 20, grid_size * 20)) self.clock pygame.time.Clock() self.reset() def reset(self): # 蛇初始位置在网格中央长度为3向右移动 cx self.grid_size // 2 cy self.grid_size // 2 self.snake [(cx, cy), (cx - 1, cy), (cx - 2, cy)] self.direction (1, 0) # (dx, dy) self.score 0 self.steps 0 self._place_food() return self._get_state() def _place_food(self): # 避免食物出现在蛇身上 while True: pos (random.randint(0, self.grid_size - 1), random.randint(0, self.grid_size - 1)) if pos not in self.snake: self.food pos break def step(self, action): # action: 0直行, 1左转, 2右转 self.steps 1 self._update_direction(action) head_x, head_y self.snake[0] new_head (head_x self.direction[0], head_y self.direction[1]) # 计算奖励 reward 0 done False # 撞墙或者撞自己 if (new_head[0] 0 or new_head[0] self.grid_size or new_head[1] 0 or new_head[1] self.grid_size or new_head in self.snake): reward -10 done True else: self.snake.insert(0, new_head) if new_head self.food: self.score 1 reward 10 self._place_food() else: self.snake.pop() # 距离引导奖励鼓励靠近食物 old_dist abs(head_x - self.food[0]) abs(head_y - self.food[1]) new_dist abs(new_head[0] - self.food[0]) abs(new_head[1] - self.food[1]) reward 0.1 if new_dist old_dist else -0.1 # 防止蛇无限绕圈限制最大步数 if self.steps 100 * len(self.snake): done True return self._get_state(), reward, done def _update_direction(self, action): # 左转 (dx, dy) - (-dy, dx) # 右转 (dx, dy) - (dy, -dx) dx, dy self.direction if action 1: # 左转 self.direction (-dy, dx) elif action 2: # 右转 self.direction (dy, -dx)动作设计这里说一下贪吃蛇的物理移动方向只有上下左右但你的“决策”只需要3个选项——保持直行、左转、右转。这个设计很巧妙因为当前方向是已知的左转右转就可以覆盖所有可能的新方向同时天然规避了“直接掉头”这个非法动作。如果用4个绝对方向作为动作空间还得额外处理掉头非法的问题。3.2 状态特征提取给AI一双看得懂局势的眼睛状态提取函数我就直接放在环境类里了def _get_state(self): head_x, head_y self.snake[0] # 食物相对位置归一化到[-1, 1] dx (self.food[0] - head_x) / self.grid_size dy (self.food[1] - head_y) / self.grid_size # 四个方向是否安全 dangers [] for d in [(0, -1), (0, 1), (-1, 0), (1, 0)]: nx, ny head_x d[0], head_y d[1] if (nx 0 or nx self.grid_size or ny 0 or ny self.grid_size or (nx, ny) in self.snake): dangers.append(0) else: dangers.append(1) # 当前方向 one-hot dir_map {(1, 0): [1, 0, 0, 0], (-1, 0): [0, 1, 0, 0], (0, 1): [0, 0, 1, 0], (0, -1): [0, 0, 0, 1]} dir_onehot dir_map[self.direction] return np.array([dx, dy] dangers dir_onehot, dtypenp.float32)四个方向的检查顺序我按“上、下、左、右”来排这个顺序并不重要关键是所有地方要一致否则训练时模型学到的特征位置是错乱的。坐标系的y轴方向可能需要确认一下pygame的坐标系原点在左上角y轴是向下的所以(0, -1)实际是向上。这个方向在状态里的语义不需要特别区分只要训练时保持一致即可模型自己会学习。3.3 DQN模型搭建一个简单的全连接网络这里我用了PyTorch搭建一个三层的全连接网络import torch import torch.nn as nn class DQN(nn.Module): def __init__(self, input_dim10, hidden_dim128, output_dim3): super(DQN, self).__init__() self.fc1 nn.Linear(input_dim, hidden_dim) self.fc2 nn.Linear(hidden_dim, hidden_dim) self.fc3 nn.Linear(hidden_dim, output_dim) self.relu nn.ReLU() def forward(self, x): x self.relu(self.fc1(x)) x self.relu(self.fc2(x)) return self.fc3(x)网络结构有3个关键点。第一隐藏层隐藏单元数量选的128这是一个折中值太高容易过拟合且训练慢太低则模型容量不足学不会复杂的策略。第二最后一层不用激活函数因为是回归任务直接输出每个动作的Q值即可。第三网络输入维度是10和我们前面状态向量的维度严格对应改状态必须同步改这里。训练核心部分我用到了两个网络当前策略网络和目标网络。每训练一定步数后直接将当前网络参数复制给目标网络def train_step(model, target_model, optimizer, batch, gamma0.95): states torch.tensor(np.array([t[0] for t in batch]), dtypetorch.float32) actions torch.tensor([t[1] for t in batch], dtypetorch.long) rewards torch.tensor([t[2] for t in batch], dtypetorch.float32) next_states torch.tensor(np.array([t[3] for t in batch]), dtypetorch.float32) dones torch.tensor([t[4] for t in batch], dtypetorch.float32) q_values model(states).gather(1, actions.unsqueeze(1)).squeeze(1) with torch.no_grad(): next_q_values target_model(next_states).max(dim1)[0] targets rewards gamma * next_q_values * (1 - dones) loss nn.MSELoss()(q_values, targets) optimizer.zero_grad() loss.backward() optimizer.step() return loss.item()optimizer用的是Adam学习率1e-3。训练时每5000步更新一次目标网络gamma取0.95。gamma这个参数试过0.99效果反而不好因为贪吃蛇是短视的博弈过分看重远期收益会让模型变得保守什么都不敢做。3.4 训练主循环epsilon贪婪策略的退火机制训练过程中最核心的一个参数就是epsilon它控制着探索和利用的比例。一开始epsilon设为1.0即完全随机行动让AI疯狂试错积累经验。随着训练推进逐步衰减到0.05即95%的时间里按照模型预测行动5%的时间随机探索避免完全陷入局部最优。def train(env, episodes5000, batch_size64, buffer_size20000): # PriorityReplay 是带优先级排序的经验池后面章节说明 replay_buffer PriorityReplay(buffer_size) model DQN() target_model DQN() target_model.load_state_dict(model.state_dict()) optimizer torch.optim.Adam(model.parameters(), lr1e-3) epsilon 1.0 epsilon_min 0.05 epsilon_decay 0.995 for episode in range(episodes): state env.reset() total_reward 0 done False while not done: if random.random() epsilon: action random.randint(0, 2) else: with torch.no_grad(): q_vals model(torch.tensor(state).unsqueeze(0)) action q_vals.argmax().item() next_state, reward, done env.step(action) replay_buffer.push(state, action, reward, next_state, done) total_reward reward state next_state if len(replay_buffer) batch_size * 4: batch replay_buffer.sample(batch_size) loss train_step(model, target_model, optimizer, batch) # 每1000步更新一次目标网络 if ep_step % 1000 0: target_model.load_state_dict(model.state_dict()) epsilon max(epsilon_min, epsilon * epsilon_decay) # 每100集打印一次训练情况 if episode % 100 0: print(fEpisode {episode}, score: {env.score}, epsilon: {epsilon:.4f})训练5000个episode大概需要多长时间我用CPUIntel i7处理器没有GPU实测大概40分钟到一个小时完全在可接受范围内。如果用GPU训练会更快但说实话这个规模的任务上CPU已经够了GPU加速的收益不大没必要为此专门配置环境。4. 改进与调优常规DQN效果不佳时怎么办4.1 遇到的第一个瓶颈模型撞墙撞得太频繁了初版训练后AI大概能得5到8分再多就很容易死。观察训练画面时发现有很明显的两个问题第一是AI经常在靠近墙的位置贴着墙走转弯不及时就撞上去了第二是食物在蛇头对角线方向时AI显得很迟疑不知道怎么规划路径。这个问题的根源在于奖励结构设计的粗糙度。之前我只做了“吃到食物10”和“撞死-10”这组稀疏奖励这是最朴素的做法但问题很明显——在吃到食物之前AI无法获得任何正向反馈等于在黑暗中摸索。训练前期AI最大的概率学到“别死”就够了想要进一步学会“接近食物”就变得很困难因为这是奖励极稀疏的任务。解决方案是加入距离引导奖励每一步根据蛇头与食物之间曼哈顿距离的变化给予小的正负奖励。如果这一步让距离缩短了0.1分变远了就扣0.1分。这个设计引导AI建立“靠近食物是好的”这一基本认知效果立竿见影——训练曲线在加入后明显上了一个台阶。4.2 加了一个优先级经验回放让AI从失败中学习训练过程中另一个观察是AI容易忘记之前犯过的错误。普通经验回放的抽样是完全随机的这就导致撞墙这种“罕见的瞬间”虽然很关键但被大量普通步数的数据稀释了出现频率低学不到教训。我用了优先级经验回放Prioritized Experience Replay来解决这个问题。核心思路是每一条经验都有“重要性”评分根据TD误差预测值和目标值的差距来排序TD误差大的说明网络对这些数据的判断准确性还很差应该加权多采样。我用了一个简单有效的实现方式——比例优先级采样class PriorityReplay: def __init__(self, capacity): self.buffer [] self.capacity capacity self.priorities [] def push(self, state, action, reward, next_state, done): # 新经验给一个较高的初始优先级 priority max(self.priorities, default1.0) self.buffer.append((state, action, reward, next_state, done)) self.priorities.append(priority) if len(self.buffer) self.capacity: self.buffer.pop(0) self.priorities.pop(0) def sample(self, batch_size): probs np.array(self.priorities) ** 0.6 probs / probs.sum() indices np.random.choice(len(self.buffer), batch_size, pprobs) batch [self.buffer[i] for i in indices] # 重要性采样权重补偿 total len(self.buffer) weights (total * probs[indices]) ** (-0.4) weights / weights.max() return batch, weights这个改造的代价很小代码量不到40行但训练稳定性和最终得分都有明显提升。其中的超参数0.6是优先级采样指数0.4是重要性采样权重指数这两个值是论文中推荐的默认值实操中不用动也问题不大。4.3 epsilon衰减策略调优找到探索与利用的平衡点初始epsilon1.0、最小值0.05、衰减系数0.995这套参数组合我是调了两次才安心的。衰减系数0.995意味着大约经过1000个episode后epsilon降到0.37左右到2000个episode时降到0.13。这个时候前期探索的经验已经积累得差不多了模型可以慢慢接手决策。但这里有个陷阱如果你把epsilon降得太快AI会过早锁定在一套不太好的策略上后期很难跳出来降得太慢则浪费训练时间。我试着把衰减系数调到0.99大概300个episode就降到0.05附近结果模型最终得分反而下降了——因为探索期太短还没见过足够的局面就变得保守了。另外一个小心得是在测试阶段直接把epsilon设为0纯贪婪策略看看模型在没有随机干扰下的真实水平。我见过不少项目训练时看平均奖励很高结果测试时表现很差一查代码发现是忘了关epsilon——测试时还在随机探索数据自然虚假繁荣。5. 训练结果与踩坑经验从及格到惊艳的差距在哪5.1 最终效果和训练曲线分析这套方案完整跑下来我得到的结果是训练中AI得分稳定在20到30分之间偶尔能上40分。对于没有视野限制、只能感知局部信息的贪吃蛇AI来说这已经算是相当不错的水平了。对比只做了基础DQN的版本平均得分5-8分改进是肉眼可见的。训练曲线有几个典型的阶段。第一阶段0-500 episode几乎0分AI在疯狂探索阶段基本秒死但经验池在快速填充。第二阶段500-2000 episode得分开始爬升从0到了8分左右AI学会了不撞墙、不撞自己但还没有形成有效的觅食策略。第三阶段2000-5000 episode得分从8分缓慢爬升到20策略逐渐成型AI开始有意识地接近食物、规避危险。当然也有性能瓶颈期过了3000个episode之后曲线增速明显变缓说明在现有状态表示和网络规模下模型已经接近它的上限了。想要继续提升可能需要更强的状态表示比如加入视野范围内的障碍物信息或者更复杂的算法比如Double DQN、Dueling DQN。5.2 训练中的高频坑和排查手册整个过程中踩了不少坑这里整理一份常见问题速查表希望能帮后来人少走弯路。问题现象原因解决方案训练不收敛损失函数持续震荡分数不涨学习率过大/奖励波动过大学习率降到3e-4检查奖励是否归一化总是撞同一面墙AI反复撞墙不换方向状态特征中方向信息丢失/网络感知不到墙确认状态包含周围4格的障碍信息检查one-hot编码在食物附近绕圈蛇头到食物距离很近却吃不到距离引导奖励导致AI学会原地微调减少距离奖励占比惩罚重复路径或加入步数上限训练后期分数不涨模型陷入局部最优探索率太低已经不会尝试新路径重启几个episode的epsilon为0.3强制探索异常高的分数但测试下降训练分数高测试分数低测试时忘了关epsilon测试阶段确保epsilon0使用纯贪婪策略训练速度极慢每1000步要跑很久画面渲染拖慢了训练速度训练阶段fps设为0或者不调用render5.3 性能不够时还能怎么改进如果你觉得当前的效果还不够刺激想挑战更高分这里有几个明确的升级方向。第一个方向是改进状态表示当前状态只考虑蛇头周围4格和食物相对位置视野实在太窄。可以把感知范围扩大到蛇头周围8格或者12格甚至用网格化的局部视野加上食物位置作为输入这样AI就能感知到更远处的危险提前规划转向。第二个方向是升级算法Double DQN解决DQN过估计问题Dueling DQN把Q值分解成状态价值和动作优势这两个改动在贪吃蛇上都有公开的实践案例能让训练更稳定、上限更高。第三个方向是设计更精细的奖励函数比如用路径规划算法BFS来计算当前蛇头到食物的最短路径长度用路径长度的变化来引导AI走向食物这样比简单的曼哈顿距离引导更精准尤其是蛇身较长时曼哈顿距离并不能反映真实的可行路径。我个人觉得如果做AI大作业BFS距离引导这个方案的性价比极高——代码量只增加几十行但效果是肉眼可见的提升优秀的作业展示效果就靠这个分水岭拉开的。6. 写在最后真正入门AI靠的是调试的耐心做完这个项目之后我的体会是AI入门的门槛从来不在代码本身而在于你是否愿意去观察训练过程、面对失败模型和分析原因。很多书上讲强化学习公式推得头头是道但只有自己亲手训练过一个模型看着它在第800个episode突然开窍、学会了绕开障碍物去吃东西那种感觉才真正让人理解“智能”是怎么涌现出来的。有一点给新手的建议先跑通一个最简单的版本哪怕效果很差和加改进的版本做对比这样你才能清楚地看到每个改进点到底带来了多少提升。我就因为这一步对比没做好有段时间把奖励函数改坏了也浑然不觉还以为是超参数不对白调了大半天。训练日志和可视化一定要从一开始就养成习惯这比代码本身更有价值。最后再分享一个小技巧训练时如果发现AI总是往同一个方向转圈可以试试调整动作的定义顺序或者在计算状态时打乱一下特征排列有时候“玄学”重排反而会打破网络初始化的固定偏好。总之多试多跑多记录你也能做出一个让同学惊叹的自动玩贪吃蛇AI。本文还有配套的精品资源点击获取
返回列表