news 2026/10/14 15:06:54

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写神经网络与遗传算法:让虚拟赛车自主学习过弯

简介:基于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 total

obs 是六维数组,action 是二维数组,分别对应五个射线读数和速度。env.last_progress 是上一帧车辆在赛道参考线上前进的距离,单位是像素,这个量累加起来就是适应度。如果车辆倒车,last_progress 是负值,倒车绕圈会积累负分,所以掉头行为天然被惩罚。这一步值得强调:如果直接用两帧之间的欧氏距离做适应度,车辆原地转圈也能积累正分数,训练出的个体会变成在大直道转圈的“陀螺”。

train 时把渲染关掉。pygame 窗口即使显示一个全黑画面,blit 和 flip 的开销也会拖慢三到五倍。我的习惯是环境类里保留一个 render 开关,训练阶段传 False,回放和验证阶段传 True;同时逻辑步进用固定 dt=1/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_steps=600): 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_num=5, pop_size=50, mut_prob=0.3, mut_scale=0.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(f"gen {gen+1} best={fitness[order[0]]:.1f} " f"avg={np.mean(fitness):.1f}") pop = evolve(pop, fitness) np.savez("best_weight.npz", w1=best[0], b1=best[1], w2=best[2], b2=best[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 代看持平还是提升。

从那以后,我每次改超过两个超参数,都强制自己做一件事:把最优个体存成独立文件,用回放模式跑三圈,记录轨迹和每圈耗时,确认行为确实更稳定后再进行下一轮训练。这个习惯替我挡掉了至少三四次“看起来收敛但实际过弯就崩”的假象。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 15:06:48

kubeadm搭建K8S集群实战:CentOS 7.6部署v1.14.2

这两年身边越来越多团队开始把服务迁到容器平台&#xff0c;Kubernetes几乎成了基础设施领域绕不开的名词。但真正从零把一个集群搭起来&#xff0c;对新手来说仍然有不少门槛&#xff0c;网上的教程要么版本太老、命令和现有环境对不上&#xff0c;要么关键步骤一笔带过&#…

作者头像 李华
网站建设 2026/10/14 15:06:31

多模态视觉+云端真机实操:AI助手如何从聊天框走向真干活

我见过太多号称“AI助手”的产品&#xff0c;打开就是聊天框&#xff0c;背后挂一个大模型API&#xff0c;用户问一句&#xff0c;它答一段。这种产品严格来说不能叫助手&#xff0c;只能叫“聊天机器人套了个壳”。今天我想认真聊一个正在被验证的新路径&#xff1a;多模态视觉…

作者头像 李华
网站建设 2026/10/14 15:06:17

Java Web三层架构企业简历管理系统:课设答辩实战解析

简介&#xff1a;一套基于Java Web的企业简历管理系统完整源码包&#xff0c;适合课程设计、毕业设计及Java初学者进阶练习&#xff0c;可直接用于毕业设计答辩演示。压缩包内共162个文件&#xff0c;包含55个java源码、17个jsp页面、10个jar依赖包、2个sql数据库脚本&#xff…

作者头像 李华
网站建设 2026/10/14 15:04:59

基于Vue和Spring Boot的在线考试系统全栈开发实践

1. 项目概述在线考试系统是教育信息化进程中的重要组成部分&#xff0c;它彻底改变了传统纸质考试的运作模式。作为一名参与过多个在线考试系统开发的技术人员&#xff0c;我想分享一个基于Web的完整解决方案&#xff0c;包含前端界面、后端逻辑和数据库设计的全栈实现。这个系…

作者头像 李华
网站建设 2026/10/14 15:04:58

WX‑0813/ AI‑ENC 降噪/ 100dB 回声消除 / 双 5W 功放/USB 免驱一站式语音

标签&#xff1a;# 语音模组 #AI 降噪 #AEC 回声消除 #DSP 音频 #嵌入式音频 #全双工对讲前言做对讲、会议、门禁、监控拾音类硬件&#xff0c;经常遇到几个老大难问题&#xff1a;环境噪声大、喇叭麦克风距离近回声严重、还要额外设计功放电路&#xff0c;主控平台多还需要适配…

作者头像 李华