手写神经网络与遗传算法:让虚拟赛车自主学习过弯

发布时间:2026/10/14 15:06:55
手写神经网络与遗传算法:让虚拟赛车自主学习过弯
简介基于Python手写神经网络与TensorFlow框架驱动赛车竞速小游戏的项目代码面向想从零理解前向传播、反向传播以及监督式/强化式驾驶策略的神经网络入门者。资源共54个文件压缩包约8.9MB包含16个py源码脚本、10个pyc编译文件、9个txt说明/数据文件、7个xml工程配置、5张地图与车辆png素材、2段wmv游戏操作录屏以及TensorFlow训练生成的index/meta/data/checkpoint模型文件已有2062人学习下载。包内提供两套并列实现一版完全用Python自研DNN完成训练与推理另一版基于TensorFlow搭建模型从数据生成、游戏封装、模型训练到AI自动测试形成完整链路地图和不同颜色车辆素材可直接替换便于观察训练效果和排错同时可借助录屏对比人工驾驶与AI控制的差异适合希望借趣味赛车场景快速建立神经网络实操概念、并对比自定义实现与框架细节的初学者。1. 神经网络操控赛车自己从零写一遍才敢说看懂了游戏 AI把“神经网络”和“游戏控制”放在一起最容易想到的是套用现成框架把画面像素丢进去跑出来一个黑箱策略。但我在模拟项目里反着做了一遍不用任何深度学习库只用一个约 290 个参数的全连接网络加上遗传算法就让一辆虚拟赛车学会在未知赛道上连续过弯。这个项目的核心不是刷高分而是把传感器读取、网络前向传播、适应度评估、权重变异这些环节全部摊开用不到三百行代码补齐整个闭环。它适合两类人一是想把“权重到底从哪来”彻底搞清楚的开发者二是不想陷入强化学习库抽象层、希望手动控制每一步数据流的入门者。从这篇文章你能拿到的是一套能直接落地复现的最小实现方案以及几个我在调参过程中反复栽过的坑。2. 设计选型传感器输入、网络拓扑和遗传算法怎么凑到一起2.1 为什么不用现成控制方案而选择“窄输入 手写网络”先把目标说清楚这个项目要的不是让赛车跑得多漂亮而是让一个从零构造的神经网络通过训练获得“看到墙就转方向”的能力。如果直接用现成的强化学习库或者把整幅画面丢给卷积网络确实能跑但排查问题的链路会变得很长——输入维度大、训练时间长、一有异常还得从数据管道开始查。我当时的判断是在自己能复现的小规模环境里信息抽取环节越简单后面网络训练和权值调优就越容易定位。所以我做了两个取舍。第一输入不用整幅图像而是用“射线传感器”距离向量。常见做法是拿赛车当前位置作为起点向几个固定方向发射线检测射线碰到赛道边缘的距离最终得到一组 0 到 1 的标量。这样网络输入从几万个像素降到几个数字数据含义非常明确数值小表示墙近数值大表示路宽。第二训练不依赖反向传播改用遗传算法。游戏的模拟环境步进速度有限跑一次评估就要几百上千帧如果每次更新都要算损失对权重的梯度算力开销反而大遗传算法只用适应度排序和变异不需要梯度信息很适合这种“能跑多少帧就记录多少帧”的场景。2.2 传感器方案五条射线加一个速度组成六维输入我在模拟环境里把赛道边界建模成两组闭合点列左边界一组、右边界一组。射线检测的思路是从车头中心出发每隔固定步长向前探测一次每探测到一个点就计算该点到左右边界的最短距离如果这个距离小于一个阈值就认为射线碰到墙此时射线长度除以最大检测距离就是归一化后的传感器读数。射线角度要覆盖车辆前方的关键视野。我选了 -60°、-30°、0°、30°、60° 五条分别对应“前左”“左前”“正前”“右前”“前右”。一开始我也试过 ±90° 的射线但窄赛道上左右两侧的射线大部分时间都被近处的墙挡住信息冗余严重角度收窄到 ±60°过弯时的有效预告距离反而更长。输入向量最终为六个值五条射线距离加当前速度速度也做归一化除以本环境里能跑到的最大速度并留一点冗余比如 200。这样整个输入分布在 0 到 1 之间网络训练时不会因为量纲差异出现权重更新偏向。参数初始值说明射线数量5角度分布-60/-30/0/30/60 度最大检测距离200超出后归一化值取 1.0步进长度4每帧射线推进的像素数碰撞阈值12点到边界小于该值视为撞墙速度归一化上限200赛车实际最高速度约 180留 20 冗余表里的“步进长度”和“碰撞阈值”其实也影响训练。步进太长会让射线跳过窄墙体区域检测精度变差阈值太大会让车还没贴到墙就被判碰撞。我测试时一般把步进取 4 像素阈值取车身宽度的一半左右。这个阈值不是拍脑袋定的车身宽度 24 像素时中心点到任一边界小于 12 就意味着车身已经压线符合碰撞直觉。2.3 网络拓扑六输入、单隐藏层、两输出结构上我没有堆更多层。输入层 6 个神经元对应五条射线加速度隐藏层 32 个神经元激活函数用 tanh输出层 2 个神经元一个输出转向角范围 -1左满舵到 1右满舵另一个输出油门范围 0 到 1。转向输出直接用 tanh 激活油门用 sigmoid 激活。为什么单隐藏层就够因为当前场景要拟合的决策面并不复杂输入只有 6 个量决策规则近似于“左墙近则右打方向”“正前距离大则加油”。单隐藏层加足够多的神经元就能表达这类非线性映射。如果你后面把输入换成 8 条射线或加入上一帧的方向差值也可以考虑加第二隐藏层但那不是这个最小实现的重点。这个结构的参数总量w1 是 6×32 共 192 个权重b1 是 32 个偏置w2 是 32×2 共 64 个权重b2 是 2 个偏置合计 290 个参数。这个体量对遗传算法很友好每个个体就是一个包含 4 个 numpy 数组的列表复制、变异都很直接不需要额外的高维数据结构。选择这个结构更多是出于“好调试”而不是“性能上限”。3. 搭出游戏环境pygame 下的赛车、赛道和射线碰撞3.1 赛车运动的简化模型赛车的物理模型我做了大幅简化不仿真轮胎抓地力也不计算横向滑动。每个时刻的状态为位置 (x, y)、朝向角 heading 和速度 speed。每帧流程根据网络输出的转向值更新 heading再根据油门值线性调节 speed然后按 speed 沿当前 heading 前移。关键参数转向灵敏度 2.4 rad/s加速度系数 300最大速度 180逻辑帧率固定 30 帧/秒。def step(self, action): steer, throttle action self.heading steer * 2.4 * self.dt self.speed (throttle - 0.2) * 300 * self.dt self.speed max(0, min(self.speed, 180)) self.x math.cos(self.heading) * self.speed * self.dt self.y math.sin(self.heading) * self.speed * self.dt第一个参数 steer 被限定在 -1 到 1乘以 2.4 后每帧最大转 2.4 rad换算成角速度约 137°/秒这个幅度在 200 像素宽的赛道上足够完成急弯也不会快到每帧乱跳。第二个参数 throttle 做过一次刹车油门输出减去 0.2 再乘系数如果网络给油门 0车子会明显减速而不是匀速滑行这能训练出“入弯收油”的行为。最大速度 180 的约束用 clamp 实现超过了就截断。这个模型虽然粗但遗传算法评估的是整体行为不需要物理级精度。3.2 射线碰撞检测的步进式实现射线检测是这套环境里最核心的传感器逻辑。我沿车身前进方向发出一条射线从车头位置开始每 4 像素取一个探测点计算该点到左右边界点列的最短距离一旦小于碰撞阈值就返回当前探测深度。def cast_ray(self, angle): max_dist 200 step 4 hit_thr 12 fx self.x math.cos(self.heading) * 12 fy self.y math.sin(self.heading) * 12 cos math.cos(self.heading angle) sin math.sin(self.heading angle) for d in range(0, max_dist, step): px fx cos * d py fy sin * d if min_dist_to_boundary(px, py) hit_thr: return d / max_dist return 1.0起点 fx、fy 比车身中心前移 12 像素模拟传感器装在车头而不是车中这样正前方检测到的距离更贴近真实碰撞点。min_dist_to_boundary 的实现是遍历左右边界点列取欧氏距离最小值。每帧五条射线最多做 100 次步进探测边界左右各 120 个点总计算量约两万次距离运算纯 Python 也能跑在 60 fps 以上。这个实现有个精度问题需要留意它是按点采样的近似射线不是严格几何求交。当步进长度大于边界点列间距时射线可能“跨过”一个狭窄的墙头而漏检。稳妥的做法是让步进长度不超过边界点间距的一半。环境里边界点间距约 6 像素步进取 4 是有意为之。3.3 游戏主循环和网络推理的串联训练阶段每跑一个个体本质就是执行一次无头模式下的游戏循环重置赛道循环调用 get_observation 取输入forward 算输出step 执行动作累计适应度直到碰撞或步数耗尽。def run_episode(chrom, env): env.reset() total 0.0 for _ in range(600): obs env.get_observation() action forward(obs, chrom) env.step(action) total env.last_progress if env.crashed: break return totalobs 是六维数组action 是二维数组分别对应五个射线读数和速度。env.last_progress 是上一帧车辆在赛道参考线上前进的距离单位是像素这个量累加起来就是适应度。如果车辆倒车last_progress 是负值倒车绕圈会积累负分所以掉头行为天然被惩罚。这一步值得强调如果直接用两帧之间的欧氏距离做适应度车辆原地转圈也能积累正分数训练出的个体会变成在大直道转圈的“陀螺”。train 时把渲染关掉。pygame 窗口即使显示一个全黑画面blit 和 flip 的开销也会拖慢三到五倍。我的习惯是环境类里保留一个 render 开关训练阶段传 False回放和验证阶段传 True同时逻辑步进用固定 dt1/30不依赖真实时钟。这样即使用无头模式跑代码训练过程和带渲染模式的行为完全一致。4. 手写神经网络与遗传训练放开框架代码反而变短4.1 前向传播和初始化参数这个项目的“自己实现”体现在这里前向传播就是一个矩阵乘加 tanh 和 sigmoid完全不需要框架。import numpy as np N_IN, N_HID, N_OUT 6, 32, 2 def init_individual(): w1 np.random.randn(N_IN, N_HID) * np.sqrt(2.0 / N_IN) b1 np.zeros(N_HID) w2 np.random.randn(N_HID, N_OUT) * np.sqrt(2.0 / N_HID) b2 np.zeros(N_OUT) return [w1, b1, w2, b2] def forward(x, chrom): w1, b1, w2, b2 chrom h np.tanh(x w1 b1) steer np.tanh(h w2[:, 0] b2[0]) throttle 1.0 / (1.0 np.exp(-(h w2[:, 1] b2[1]))) return np.array([steer, throttle])w2 的形状是 (32,2)两列分别对应转向和油门输出所以 w2[:,0] 取转向列w2[:,1] 取油门列。初始化参考了 He 初始化虽然 tanh 激活更严格的说法是 Xavier但在遗传算法场景里这两者差异很小——因为每一代的权重都要经过选择、复制和变异重写初始尺度只要不把 tanh 推入饱和区太深就行。偏置初始化为 0 也是常用做法变异过程会很快探索出合适的偏置值。4.2 适应度评估和选择先跑遍全种群再留五个精英遗传算法的核心就是选择加变异。我把评估和进化拆成两个函数便于单独调整。evaluate 负责跑一局evolve 负责从老种群生成新种群。def evaluate(chrom, env, max_steps600): env.reset() score 0.0 for _ in range(max_steps): obs env.get_observation() action forward(obs, chrom) env.step(action) score env.last_progress if env.crashed: break return score def evolve(pop, fitness, elite_num5, pop_size50, mut_prob0.3, mut_scale0.2): order np.argsort(fitness)[::-1][:elite_num] elite [pop[i] for i in order] new_pop [[w.copy() for w in params] for params in elite] while len(new_pop) pop_size: parent elite[len(new_pop) % elite_num] child [] for w in parent: nw w.copy() mask np.random.rand(*nw.shape) mut_prob nw[mask] np.random.randn(mask.sum()) * mut_scale child.append(nw) new_pop.append(child) return new_pop选择阶段按适应度排序拿走最优的五个精英原样复制进下一代保证最优解不会丢失。变异阶段对每个权重矩阵按概率 0.3 随机抽位置叠加标准差 0.2 的高斯噪声。填充阶段由五个精英轮流变异生成剩余 45 个个体不设交叉操作。在 290 个参数规模的小搜索空间里纯变异已经完全够用交叉反而可能把两个不同行为模式的权重组合坏。elite_num 取 5、pop_size 取 50 是一个常见平衡点。elite 太小容易早熟elite 太大会让下一代个体非常相似探索能力下降。mut_prob 控制每次变异的权重比例0.3 意味着每代大约 87 个权重发生变动如果调到 0.5 以上训练结果就接近随机搜索。mut_scale 是噪声强度初始 0.2 是个稳妥值连续五代表现停滞时可以降到 0.1 重新跑。4.3 训练主循环从第 1 代到第 30 代训练主循环不做玄学就是反复执行“评估全体 → 保存最优 → 进化”。pop [init_individual() for _ in range(50)] best_history [] for gen in range(30): fitness [evaluate(c, env) for c in pop] order np.argsort(fitness)[::-1] best pop[order[0]] best_history.append(fitness[order[0]]) print(fgen {gen1} best{fitness[order[0]]:.1f} favg{np.mean(fitness):.1f}) pop evolve(pop, fitness) np.savez(best_weight.npz, w1best[0], b1best[1], w2best[2], b2best[3])每代 50 个体评估单局最多 600 帧前期个体大多在 100 帧内撞墙30 代全跑完大约需要几千局模拟。如果开着渲染窗口跑这个时间会拖到无法接受无头模式下通常几分钟到十几分钟能收敛。best_history 是判断训练是否正常的最直接依据正常曲线应该前 10 代快速上升后面趋缓如果 10 代都没明显增长多半是传感器或适应度的问题别急着加代。保存参数我用 np.savez 而不是 np.save因为网络权重是四个数组的列表np.save 处理起来不方便。回放时用 np.load 读回来重新组装成 chrom 即可。5. 避坑与常见问题六个最容易让人误判“训练无效”的细节5.1 输入没归一化变异算子像在盲人摸象现象适应度长时间停在个位数偶尔出现一个高分个体但下一代立刻崩回低位。原因射线距离在 0 到 200 之间速度在 0 到 180 之间如果不归一化速度维度的权重只要变一点输出变化就很大而射线维度的信息被完全淹没。遗传算法没有损失函数帮你自动缩放特征它只看变异后输出的变化量纲大的维度天然“更响”。解决观察向量里每个量都除以自己的量程最大值速度上限留 20% 冗余。我出现过一次把速度直接除 180 而不是 200结果车速长时间满格时归一化值一直是 1.0等于丢掉了速度信息把上限放宽后问题立刻消失。5.2 撞墙就终止评估弯道训练直接死锁现象大部分个体在第一个弯道就阵亡适应度曲线走平怎么调变异率都没有起色。原因碰撞后立即结束评估等于给“过弯失败”极重处罚。早期随机网络几乎都过不了第一个弯道这样筛选不出一丁点“转弯倾向”所有个体都是刚起步就撞墙分数差距极小进化压力建立不起来。解决把“撞墙判死”改成“撞墙回退”。具体做法是碰撞后回退到上一帧安全位置速度减半扣除 20 分允许每局最多回退三次三次之后仍在墙边才结束评估。这样个体能跌跌撞撞过完第一个弯让“转动方向盘改变了结果”这一信号进入适应度排序。5.3 变异幅度过大像随机游走而不是进化现象上一代最优个体跑了 350 分下一代最优只剩 80 分最优质反复丢失。原因mut_scale 设得过大在优秀个体周围叠加了太多噪声等于把刚搜到的好位置直接打乱。还有一种隐蔽情况实现 evolve 时把精英也做了变异导致所谓最优秀的个体其实已经不再是最优参数。解决把 mut_scale 控制在 0.15 到 0.25连续三代 best 不增长时降到原来的一半再跑。精英部分直接原样复制不对精英做变异保证至少有一个稳定可回放的版本留存。保存文件时也要确认取的是 pop[order[0]]而不是随意索引我在这个位置吃过一次亏存了一个第三名个体当最优回放时怎么都不对。5.4 射线太少急弯永远反应不过来现象高速直角弯处车辆总是以“直刺”角度撞到弯道外墙回放路径贴着外缘擦过。原因五条射线覆盖范围只有前方 ±60°对侧向墙体的感知太晚。最大检测距离 200 像素对高速来说约等于 0.5 秒的预告时间再减去射线间隙实际有效反应时间更少。解决把射线加到 8 条角度分布改为 -80°、-50°、-25°、0°、25°、50°、80°网络输入从 6 维升到 9 维。注意隐藏层最好也同步从 32 提到 40否则增加输入维度后单隐藏层容量会偏紧。同时把最大检测距离提高到 250物理 dt 从 1/30 改成 1/60——后者逻辑计算量翻倍但对入弯抢方向非常有效。5.5 适应度只用距离网络学会了“考试技巧”现象best 分数涨到某个平台就停住车在大直道上反复来回开始终不完成整圈。原因我的 last_progress 是按参考线投影算的直道上前进得分效率高弯道容易损失速度。遗传算法会敏锐地发现这一点策略偏向于“找一段直道反复刷距离”而不是“完成一圈”。解决加入里程点奖励。赛道上每 100 像素设一个检测点车辆首次通过检测点时一次性加 500 分。分数结构变成“连续行进得分 里程碑奖励”仅仅来回刷直道的个体拿不到后半程的里程奖励。还有一个验证技巧把每代最优个体的轨迹点存下来跑完后画一个叠加图肉眼看是不是在绕圈。5.6 无头模式只关了画布没有解耦渲染循环现象关闭渲染后训练速度依旧很慢CPU 占用却没有明显下降。原因pygame 的 clock tick 仍然按照真实时间阻塞在帧率控制上逻辑步进没有独立出来。解决把游戏更新完全改为固定 dt 步进绕过真实时钟。训练循环里直接调 update_with_dt不需要 pygame.time.Clock().tick 参与同步。判断标准是关掉渲染后每秒能执行的逻辑帧数至少提升 3 倍如果没提升说明渲染开关只是藏了窗口没有真正裁掉绘制开销。6. 训练完成后怎么回放验证顺带聊一个进阶改动拿到 best_weight.npz 之后第一件事不是看数字而是把车跑起来逐帧看轨迹。回放模式打开渲染每帧记录 (x, y)结束后把轨迹画在赛道图层上。如果轨迹在弯道外缘反复擦边说明传感器覆盖率或逻辑步长还有问题不要急着改网络结构。env.render_enable True env.record_trail True env.reset() chrom [np.load(best_weight.npz)[k] for k in [w1, b1, w2, b2]] for step in range(600): obs env.get_observation() action forward(obs, chrom) env.step(action) if env.crashed: break env.draw_trail()每一轮的轨迹叠加图能暴露两类问题一是过弯时轨迹不够平滑二是直道速度过低导致圈时偏长。前者通常指向逻辑 dt 过大后者指向油门网络输出整体偏低。看完轨迹再决定下一轮调参方向比盯着一堆曲线猜测靠谱得多。一个值得尝试的进阶改动是加入刹车输出。目前网络只有转向和油门两个输出没法表达主动减速在高速进入紧弯时只能靠收油被动降速。把输出层改成三个神经元第三个输出接 sigmoid作为刹车值。每帧先按刹车值把速度乘以 (1 - brake*0.9)再走常规的油门更新这样车辆入弯时能明显减速。网络参数从 290 个增加到 322 个变异的搜索难度略有上升建议把 mut_scale 同步降到 0.15多跑 5 代看持平还是提升。从那以后我每次改超过两个超参数都强制自己做一件事把最优个体存成独立文件用回放模式跑三圈记录轨迹和每圈耗时确认行为确实更稳定后再进行下一轮训练。这个习惯替我挡掉了至少三四次“看起来收敛但实际过弯就崩”的假象。希望帮到你。本文还有配套的精品资源点击获取