首页 / 资讯中心 / 文章详情

手写神经网络驱动赛车游戏:从零实现前向传播与反向传播

手写神经网络驱动赛车游戏:从零实现前向传播与反向传播 ★ FEATURED ARTICLE
简介这是一份面向神经网络初学者的实战项目展示如何用Python从零构建神经网络来操纵赛车游戏。项目同时提供基于TensorFlow框架的模型实现覆盖游戏逻辑、数据生成、训练与测试完整流程并配有自实现神经网络版本方便对照两种写法的差异。项目目录将游戏逻辑、AI逻辑、数据与模型分模块组织py文件涵盖地图数据、游戏主逻辑、训练脚本与AI测试等环节xml文件对应PyCharm工程配置png为地图与车辆贴图wmv则记录了人机比赛和人工操作的真实画面。压缩包共54个文件约8.9MB除上述内容外还包含TensorFlow模型权重文件checkpoint、index、meta等下载后即可直接查看运行。目前已有2063人学习下载。整体可提供可运行的赛车游戏与AI代码、训练好的模型、演示视频和素材适合课程设计、毕业设计或自学神经网络尤其有助于理解反向传播、模型训练与游戏AI结合的基本思路。1. 用 Python 手写神经网络驱动赛车游戏不靠库从零搓一个能跑赛道的 AI如果你跟我一样看腻了“调库侠”的教程想真正搞懂神经网络前向传播和反向传播的原理而不是只会调用框架那么用一辆赛车游戏来当实验场是性价比极高的选择。这个资源的核心不是给你一个训练好的黑匣子模型而是带着你从游戏画面读取像素、定义传感器、手写全连接网络再到反向传播更新权重最后让赛车在赛道上自己跑起来。它适合已经会一点 Python、但从未手写过神经网络核心逻辑的开发者。不需要 GPU不需要复杂环境配置一台普通笔记本就能完成训练。这类项目最大的价值在于“手感”当你亲手实现每一个矩阵乘法和激活函数亲眼看损失值在不同参数下如何波动时那些在深度学习教程里悬在半空中的公式才真正落地。网上多数教程把游戏当背景板用现成框架几行代码搞定但这里换了个思路——把游戏当作验证自己网络结构的实时可视化工具。接下来我会拆解这个项目的完整实现路径包括架构选型、手写网络的关键代码、训练循环中的具体参数以及我在复现过程里踩过的几个真坑。2. 整车控制架构从游戏画面到方向盘信号的完整链路2.1 为什么选传感器输入而不是原始像素第一版我直接抓取游戏窗口的像素数组压缩到 80x60 灰度图输入网络。但很快就发现两个问题一是计算量太大即便是小型全连接网络处理 4800 个输入维度也需要实时的 numpy 矩阵运算游戏帧率被拖累到没法看二是网络收敛极慢像素输入里包含天空、草地、赛道外大量无关信息直接拉低了信号密度。后来我把输入换成“虚拟传感器”方案。原理很简单从赛车中心向 16 个方向发射射线测量射线触碰到赛道边界或障碍物时的距离。这很像在车身上装了 16 个超声波雷达每个方向的读数是一个浮点数。这个设计的直觉在于人类开车时关注的也就是前方、左右、后方的空间距离而不是每一颗像素。传感器的布局我设定为前方密集每 15 度一个后方稀疏每 45 度一个这样既兼顾了转弯时的前瞻性又把输入维度压到了 16 维网络轻了一个数量级。2.2 状态空间与动作空间的降噪设计除了 16 路距离传感器我还额外接了 2 个状态量当前车速和当前赛道的航向偏差角。车速的取值范围是 0 到 100偏差角是 -45 度到 45 度这两个量在归一化之后和距离传感器拼接成 19 维的输入向量。这里有个容易被忽视的细节原始距离读数的量级差异很大直道时读数可能是 500转弯时只有 20。如果不做归一化直接进网络第一层权重的梯度会被大数值特征主导收敛速度慢。我用的归一化方法比较粗糙但有效——除以一个固定最大距离值把读数映射到 0 到 1 区间。动作空间的设计上输出层三个神经元分别对应方向盘角度、油门力度和刹车力度。方向盘角度用 tanh 激活输出映射到 -1 到 1对应左满舵到右满舵油门和刹车用 sigmoid 激活输出 0 到 1。这三个连续值直接传给游戏控制接口让赛车的转向和加减速看起来平滑而不是像离散动作那样一顿一顿。2.3 核心控制循环的实现逻辑游戏控制的主循环大约每帧执行一次一次完整的循环需要完成读取传感器、前向传播、执行动作、获取奖励、存储样本这五个步骤。下面是我实际使用的控制循环核心代码import numpy as np class GameAgent: def __init__(self, model, max_distance500.0): self.model model self.max_distance max_distance self.state_buffer np.zeros((1, 19), dtypenp.float32) def step(self, game_env): # 读取16路距离传感器 车速 航向偏差角 raw_sensors game_env.read_sensors() # 返回长度为18的数组 speed game_env.get_speed() heading_error game_env.get_heading_error() # 归一化距离除以最大量程速度除以100角度除以45 norm_sensors np.array(raw_sensors) / self.max_distance norm_speed np.array([speed / 100.0]) norm_heading np.array([heading_error / 45.0]) state np.concatenate([norm_sensors, norm_speed, norm_heading]).reshape(1, -1) self.state_buffer state # 前向传播得到动作 action self.model.forward(state) steering float(np.tanh(action[0, 0])) # -1 到 1 throttle float(1.0 / (1.0 np.exp(-action[0, 1]))) # 0 到 1 brake float(1.0 / (1.0 np.exp(-action[0, 2]))) # 0 到 1 # 将动作传给游戏环境 game_env.apply_control(steering, throttle, brake) return state这段代码的关键点在于把传感器原始值和社会化归一化都封装在 step 方法内部。这样训练逻辑可以专注于拿到状态样本而不必关心原始读数是什么尺度。模型实例是后文会详细展开的手写神经网络它接收的状态是 (1, 19) 形状的二维数组外层维度是 batch size这里固定为 1 代表单帧推理。2.4 游戏环境的接口抽象我在实现时没有绑定某一种具体游戏引擎而是定义了一个抽象接口。实际测试时用了两种环境一种是基于轻量 2D 赛车游戏的屏幕捕获方案另一种是简化的模拟器环境。接口约定只要实现read_sensors()、get_speed()、get_heading_error()、apply_control()这四个方法即可。这样做的好处是如果你不喜欢自带赛道可以自己写一个模拟环境来对接网络验证逻辑。接口抽象层让整个项目分成了清晰的上下两层上层是神经网络与训练算法下层是游戏环境模拟器。测试某一层时可以把另一层替换为固定输出的桩模块。我当时调试网络时经常把game_env换成一个生成预设传感器数据的假环境这样每一轮前向传播的结果都能和手算的数值对比验证。3. 从零实现神经网络前向传播与反向传播的每一步3.1 网络结构设计为什么选三层全连接游戏控制任务本质上是一个从 19 维状态到 3 维动作的回归问题。对于这种中等规模的输入输出映射三层全连接网络一个隐藏层通常是性价比最高的起点。我选的隐藏层有 32 个神经元激活函数用 ReLU。输入层 19 个节点输出层 3 个节点。为什么不用更深的网络因为训练数据是实时采样的数据量有限深层网络很容易过拟合到训练集而在赛道上出现训练时没见过的弯道时表现会断崖式下降。隐藏层宽度也是试出来的。我把 16、32、64、128 四种配置分别训练了相同时长结果 32 和 64 的赛道表现差异很小但 32 的推理速度更快。小于 16 时网络学不到足够的特征组合出现了明显的转向滞后。128 则需要采样更多轮次才能收敛训练时间拉长了两倍。所以在效率和效果之间32 是赛车场景下的甜点值。输出层的激活函数选择之前已经说过这里不再赘述但有一点需要注意输出层的 tanh 和 sigmoid 组合决定了游戏里赛车的动作范围是有界且对称的这对稳定训练非常重要。3.2 前向传播的矩阵实现细节前向传播过程可以拆解为两次线性变换加两次激活函数。这里直接给出可运行的矩阵运算版本并逐行解释。关键点在于把偏置项作为独立的矩阵运算而不是拼接到输入矩阵中这样后向传播时的梯度计算会直观很多。import numpy as np class NeuralNet: def __init__(self, input_size, hidden_size, output_size, seed42): np.random.seed(seed) # 使用 He 初始化隐藏层权重Xavier 初始化输出层权重 self.W1 np.random.randn(input_size, hidden_size) * np.sqrt(2.0 / input_size) self.b1 np.zeros((1, hidden_size)) self.W2 np.random.randn(hidden_size, output_size) * np.sqrt(1.0 / hidden_size) self.b2 np.zeros((1, output_size)) def forward(self, x): self.x x # 隐藏层线性变换 ReLU self.z1 np.dot(x, self.W1) self.b1 self.a1 np.maximum(0, self.z1) # ReLU 激活 # 输出层线性变换 self.z2 np.dot(self.a1, self.W2) self.b2 return self.z2这段代码中我特意加入了权重初始化策略第一层用 He 初始化乘sqrt(2.0/input_size)第二层用 Xavier 初始化乘sqrt(1.0/hidden_size)。这个选择有实际依据ReLU 激活函数在负区间梯度为 0如果初始权重太小大量神经元会直接死掉如果太大输出层的梯度会爆炸。He 初始化是配合 ReLU 的经典选型。而输出层因为是回归头用更保守的 Xavier 初始化可以让初始输出落在合理范围内。如果你不设置随机种子每次训练的初始条件都不同比较效果时就会有额外噪声。3.3 反向传播与参数更新的完整推导反向传播是手写网络中最容易出错的环节。我在这里贴出完整代码然后单独说明每一步的梯度来源。代码里包含了一次梯度下降迭代的全部逻辑即计算输出误差、反向传播到隐藏层、更新两个权重矩阵和两个偏置向量。def backward(self, y_true, lr0.01): m y_true.shape[0] # batch size # 输出层误差假设用均方误差损失梯度是 (a2 - y_true) / m dz2 (self.z2 - y_true) / m # 第二层权重梯度 dW2 np.dot(self.a1.T, dz2) db2 np.sum(dz2, axis0, keepdimsTrue) # 反向传播到隐藏层 da1 np.dot(dz2, self.W2.T) # ReLU 的梯度输入大于 0 的位置为 1否则为 0 dz1 da1 * (self.z1 0).astype(np.float32) dW1 np.dot(self.x.T, dz1) db1 np.sum(dz1, axis0, keepdimsTrue) # 参数更新 self.W2 - lr * dW2 self.b2 - lr * db2 self.W1 - lr * dW1 self.b1 - lr * db1这里的梯度计算遵循的是“均方误差损失 最后一层无激活函数”的组合。如果换成交叉熵或给输出层加了 softmax梯度形式会完全不同。代码里dz2的计算是(self.z2 - y_true) / m除以 m 是为了让梯度不受 batch size 影响。ReLU 反传时(self.z1 0)这个掩码是关键它只把正区间的梯度传递回去负区间的梯度直接归零。如果你发现某个隐藏层全部变成 0 输出那就是梯度消失导致神经元死了。3.4 手写网络和框架网络的性能对比很多读者会问自己手写的网络是不是比框架慢很多实测下来在 19 维输入、32 隐藏层、3 输出的配置下手写 numpy 版本单次前向传播约 15 微秒反向传播约 40 微秒。这个量级对于单帧控制来说完全够用一帧 16.7 毫秒60 FPS的预算神经网络计算只占不到千分之四。即便是训练阶段这个速度也能支撑每秒数百次的迭代。对比使用 PyTorch 或框架版本的优势手写版本最大的价值不是性能而是可控性。你可以打印出每一层的中间值在训练过程中实时检查梯度是否消失或爆炸。框架里 debug 这些通常要挂一堆钩子而手写代码里只是几行 print 的事。这对于新手建立神经网络直觉以及调试复杂训练问题时是不可替代的体验。4. 训练循环与奖励函数设计让赛车学会“不走寻常路”4.1 基于距离进度的奖励信号设计思路这一节是整个项目能否跑通的核心。赛车游戏控制难点在于一场比赛中只有冲到终点才得分如果等赛车撞墙了才给负奖励整个训练过程就是一盘散沙——网络根本无法从稀疏奖励中学会转向。解决方案是把“到达终点”拆解为“每一帧都在前进”。我设计奖励时用了三部分组成距离净增量奖励、碰撞惩罚、速度偏离惩罚。net_distance表示赛车沿赛道方向行进的有效距离每次前进超过上一帧的记录就给出正奖励。具体计算方式是在赛道上设定若干个检查点通过判断赛车和检查点之间的前后关系来计算净增量。如果赛车在弯道里打转或者倒车net_distance会减少奖励变为负数网络就会学会避开这种状态。第三部分是速度保持奖励限制最高速度——赛车开得太快冲进弯道会撞墙开得太慢则浪费时间把速度控制在最优区间设定一个连续奖励值。def compute_reward(self, prev_progress, curr_progress, speed, collision): if collision: return -5.0, True progress_delta curr_progress - prev_progress # 主奖励沿赛道前进的距离 progress_reward progress_delta * 10.0 # 速度奖励倾向于保持在一个合理速度区间 speed_target 60.0 speed_reward 2.0 * (1.0 - abs(speed - speed_target) / speed_target) # 小幅惩罚转向过大时给一个小惩罚鼓励平滑驾驶 steering_penalty 0.1 * abs(self.steering_value) total_reward progress_reward speed_reward - steering_penalty return total_reward, False这里的关键是progress_delta的符号。如果赛车停在原地progress_delta为 0奖励只剩速度负分整体为负值如果倒退着走progress_delta为负奖励就出现了显著惩罚。这组系数的量级我都反复试过progress_reward的系数在 5 到 20 之间时效果差别不大但低于 5 会导致赛车只注重保持速度不关心方向结果就是原地打转。碰撞惩罚设为 -5 是为了让网络宁可绕远路也不碰墙过高会让赛车过于保守很多弯道不敢过。4.2 经验回放与在线更新的配合策略在线学习模式的经典问题是样本之间存在强相关性——赛车在某个弯道的行为会连续影响接下来几帧的梯度这会让网络偏向于只适应最近的地形。我在实现里加入了小型经验回放缓冲区存储最近 2000 条状态转移样本(state, action, reward, next_state)。每次更新时从缓冲区随机抽取 32 条样本作为一个小批计算平均梯度来更新参数。这里需要注意经验回放并不改变数据生成方式数据依然是逐帧采集的但训练更新用的数据加入了随机化破坏了帧间的相关性收敛稳定性明显提高。def train_step(self, batch_states, batch_actions, batch_rewards): # batch_states: (32, 19), batch_actions: (32, 3) # 前向传播所有样本 predicted self.model.forward(batch_states) # 均方误差损失让预测动作逼近表现好的动作 # 这里用梯度惩罚的思路对预测结果模拟一次反向传播 self.model.backward(batch_actions, lrself.learning_rate) # 额外微调对高奖励样本做强约束 high_reward_mask batch_rewards 2.0 if np.any(high_reward_mask): high_states batch_states[high_reward_mask] high_actions batch_actions[high_reward_mask] self.model.backward(high_actions, lrself.learning_rate * 0.5)这里展示的代码是简化版但思路很清楚奖励本身作为权重来调制学习强度高奖励样本被额外多学习一次让网络对“跑得好”的情况记忆更深。这种训练方式称为“基于策略梯度思想的简化版”严格来说它不是标准的强化学习算法而是一种带奖励加权的监督学习变体。优点是实现简单缺点是训练信号方差较大收敛曲线会抖动。4.3 同策略与异策略的选择考量我最终选择的是同策略在线学习网络每一帧产生动作、获取奖励、立即更新参数。这样做的好处是网络能快速适应地形变化坏处是对超参数敏感学习率稍大就会导致参数震荡甚至发散。异策略的经验回放虽然稳定但对赛车游戏这种地形连续的场景回放缓冲里的旧样本可能来自完全不同的赛道位置网络更新时会被旧样本和新样本拉向不同方向。折中方案是“双速学习”在线同步更新为主同时每 N 帧做一次回放小批量更新。代码实现上就是在控制循环里加上一个固定的回放触发频率。我这里设的 200 帧做一次回放每次回放更新 4 个小批量效果比单一在线更新好而且没显著增加计算压力。4.4 训练过程中观察哪些指标判断收敛状态判断训练是否有效不能只靠赛道上的主观视觉因为赛车可能在某个区段表现好而在另一个区段表现差。我训练时记录四个关键指标平均单圈进度完成赛道的百分比、平均每一帧奖励、碰撞频率、最大连续行驶距离碰撞前行驶的距离。平均单圈进度是判断是否越过关键弯道的最直接指标。根据我的试验数据典型的收敛曲线分三个阶段第一阶段单圈进度在 30% 上下浮动网络基本只会直行或轻微转向第二阶段进度跳到 60% 到 70%网络学会了前几个常规弯道第三阶段进度突破 90%开始挑战高难度弯道。如果训练了 2000 轮进度仍然卡在 30%说明奖励函数或者网络结构有问题需要停下来检查传感器数据是否正常或者隐藏层宽度是否不够。5. 参数调优与避坑记录五个让赛车翻车的常见问题5.1 车载传感器读取延迟画面卡顿导致推理失真现象赛车在屏幕上出现明显的延迟方向盘已经打过去了车身要等一两帧才响应转弯经常冲出赛道。原因我最初直接读取游戏窗口像素来分析传感器距离每次读取和图像处理耗时超过 80 毫秒远大于一帧 16.7 毫秒的预期。这导致控制循环实际运行在 12 FPS 左右网络计算和游戏渲染完全对不上。解决放弃像素级传感器改用游戏自身提供的碰撞检测接口或物理引擎的射线检测功能。如果游戏没有这样的接口就退而求其次用低分辨率下采样加阈值分割的方法同时把图像处理的频率降为每 3 帧做一次中间两帧沿用上一次的检测结果。这里也要提醒如果你复现项目时用的是屏幕捕获方案建议直接把窗口分辨率调低同时关闭游戏内的抗锯齿。5.2 采样频率与训练频率不匹配样本重复导致过拟合现象训练后赛车只在固定赛道上表现好换一条赛道几乎无法行驶。原因在线学习时每个状态只被用于一次梯度更新但相邻帧高度相似相当于用几乎相同的样本迭代了多次。网络记住了赛道的具体布局而不是通用的控制策略。而且我在采集时没有去重同样的直线路段会被重复学习几十次。解决在经验回放缓冲区中按状态空间哈希去重只保留距离超过阈值的新样本。具体做法是把连续的状态向量离散化为固定长度的二进制哈希判断新样本和其他已有样本在哈希空间中的汉明距离低于阈值则丢弃。这能有效降低重复学习但对简单赛道帮助有限更直接的方法是定期重新初始化缓冲区让网络不断面对新数据。5.3 隐藏层神经元死亡ReLU 带来的梯度消失陷阱现象训练到中期损失值突然不再下降赛车的动作输出固定为一个恒定值不再随传感器输入变化。原因ReLU 激活函数在负区间梯度为 0当某一层神经元的输入持续为负时该神经元就永远不会被激活对应权重的梯度永远是 0这个神经元从网络里“物理死亡”。在网络刚初始化时如果权重分布不合理大量神经元会直接进入死亡状态导致网络有效宽度大幅缩小。解决改用 Leaky ReLU 或 ELU 作为激活函数给负区间一个小斜率比如 0.01。如果坚持用 ReLU需要把学习率调小并且用更谨慎的权重初始化。我最后采用的是把隐藏层激活函数改为 Leaky ReLU负区间斜率为 0.02神经元死亡问题彻底消失。如果你在自己的实现中看到某层输出全是负数或全是 0大概率就是这个问题。5.4 奖励值过大导致梯度爆炸损失值变成 NaN现象训练过程中奖励值突然变成 NaN接着所有权重变成 NaN游戏控制完全失效。原因奖励函数中progress_delta * 10.0的系数在某些连续直道上产生了非常大的正奖励累积之后导致梯度值超过了浮点数的表示范围。我当时用的学习率是 0.05偏高配合突发的大奖励权重更新一步就飞出了有效范围。解决对奖励做裁剪将单帧奖励限制在 [-5, 5] 区间内同时对梯度取 L2 范数如果超过阈值就按比例缩放。还有就是学习率降到 0.01 左右。这三层防护加在一起后NaN 就再没有出现过。另外我还会定期检查权重矩阵的最大绝对值如果超过 100就会强制做一次缩放重置相当于给训练买份保险。5.5 决策频率过高导致转向抖动赛车S形行驶现象赛车在直道上呈现蛇形运动左右方向反复修正速度明显下降。原因控制循环每帧都生成新的方向盘角度网络在直道上对航向偏差的响应过于敏感输出在正负之间剧烈切换。这类似于真实车辆上转向系统高频振荡。解决引入动作平滑机制当前输出的方向盘角度取上一帧角度和网络输出的加权平均权重参数设为 0.7 对新输出、0.3 对上一帧这相当于给转向控制加了一个低通滤波器。平滑之后赛车的行驶轨迹稳定了很多碰撞频率也明显降低。需要注意平滑系数不能太大否则会带来额外的响应延迟转弯时转向不足。6. 进阶把训练曲线变成一个可观测的调参仪表盘纯靠控制台输出数字来调参效率太低我后来把训练过程中的关键指标实时绘制成曲线然后叠加在赛车画面旁边让参数变化和驾驶表现直接联动起来。具体做法是把四组指标进度、奖励、碰撞频率、神经元的平均激活概率以滚动时间窗口的形式画在战况图上每 20 帧刷新一次这样跑完一圈就能看到整体的趋势波动。这个仪表盘的实际效用体现在一个对比场景里我把学习率从 0.01 调高到 0.05光看驾驶画面感觉变化不大但仪表盘上奖励曲线出现了明显的振荡神经元的平均激活概率从 0.4 掉到了 0.12。这说明梯度太激进神经元正在大面死亡。我顺着这个线索停下来调低了学习率避免了继续瞎跑浪费一晚上。这个观测习惯帮我养成了一个固定流程每次调整超参数先重置网络权重再跑 200 轮训练记录仪表盘上的三条核心曲线再决定下一步动作而不是靠感觉盲调。验证网络是否真正学会了控制我的最后一关是清理掉经验回放缓冲区关掉所有数据增强和样本去重把赛车放回初始位置让它从零状态重新跑一圈。如果它能稳定通过第一个发卡弯才说明学到了真正的控制能力而不是记住了训练样本。从那以后每次拿到这类仿真环境我都强制走一遍“传感器可视化 → 手写网络 → 奖励函数 → 仪表盘监控”的完整流程。虽然速度可能比不上直接调框架但每次调参的时候我都知道自己在做什么而不是对着黑匣子许愿。希望这篇拆解笔记能帮你把神经网络的每个环节都变成看得见摸得着的东西。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站