简介:这份压缩包围绕用 Python 从零实现神经网络并操控赛车游戏,面向神经网络入门者、游戏 AI 爱好者以及希望把 TensorFlow 落到小项目中的开发者,重点解决网络结构搭建、训练数据生成与游戏决策联调等问题。包内共有 54 个文件,整体约 8.9MB,以 16 个 py 源码为主体,辅以 pyc 缓存、txt 说明、xml 工程配置、png 地图与车辆素材、wmv 演示视频,以及模型 checkpoint、meta、index 等训练权重文件,目录按游戏逻辑、AI 逻辑和模型训练拆分。内容同时给出 TensorFlow 实现与手写 DNN 两条路线,并包含激活函数、数据采集、模型训练和 AI 测试等模块,配合人机比赛与人工操作录屏,便于对照理解输入输出映射、样本采集、损失变化与模型加载等环节,也能为二次改造和排错提供参照。目前已有 2064 人学习下载,适合用作神经网络课程实验、毕业设计原型或小型游戏 AI 项目的参考。
1. 需求切入:用 Python 自己实现神经网络操纵赛车游戏
用 Python 自己实现神经网络去操纵赛车游戏,真正卡人的地方通常不是矩阵乘法,而是把游戏状态、动作输出和训练循环接成一条能复现的链路。这个方案面向两类人:要做游戏 AI 课设或毕设的学生,以及想在不引入深度学习框架的前提下,把神经网络跑进实时程序里的开发者。它解决三件事:赛车环境如何抽象成固定接口;只用 NumPy 手写 MLP 的前向与反向;用规则控制器采数据、行为克隆训练,再用圈速和碰撞率验收。整套方案按模块拆开,照着改就能落到自己的 2D 赛车 Demo 上。
2. 总体架构设计:游戏适配、感知与决策的三层拆分
赛车游戏本身带物理引擎、渲染循环和碰撞检测,神经网络只负责“看状态、出动作”。如果把训练代码直接塞进游戏主循环,后面换赛道、改传感器、调决策频率都会互相牵扯。我一般会把整个方案拆成五块:游戏适配层、感知层、决策层、训练层、评估层。游戏适配层负责把游戏内部状态翻译成固定长度的观测向量,并把动作 id 翻译成油门、转向、刹车;感知层只做归一化和异常值裁剪;决策层就是那个自己实现的神经网络;训练层负责演示数据、损失、反向传播和参数保存;评估层用固定种子跑圈,输出圈速、碰撞次数和动作变化率。五块之间用字典或数据类传参,不让游戏对象泄漏进训练代码。
| 模块 | 输入 | 输出 | 主要职责 |
|---|---|---|---|
| 游戏适配层 | 游戏内部状态 | Observation | 采样射线、速度、航向误差,执行动作 |
| 感知层 | Observation | 归一化向量 | 裁剪射线距离,缩放到 0~1 |
| 决策层 | 九维向量 | 五类动作概率 | MLP 前向推理,输出动作 id |
| 训练层 | 演示数据 | 网络参数 | 交叉熵损失、反向传播、参数保存 |
| 评估层 | 固定种子赛道 | 圈速、碰撞率 | 对比规则控制器和网络策略 |
2.1 五个模块的职责边界与数据流
数据流从reset()开始:适配层把赛车放到随机起点,返回第一帧观测;决策层拿到九维向量,输出一个动作 id;适配层在内部保持这个动作若干帧,再返回下一帧观测和奖励。训练层只认(obs, action)样本对,不关心游戏窗口有没有开。评估层则反过来,只跑网络策略,不更新参数。这样拆的好处是,训练时可以把游戏速度调到几倍,试跑时再恢复实时;换赛道只改适配层,网络输入维度不变。
动作空间用五类离散动作:左转、右转、直行、加速、刹车。连续输出转向角和油门当然更细,但行为克隆的标签更难标,规则控制器也不好写。课设和毕设的时间有限,先把五类动作跑通,再考虑把左转、右转拆成轻重两档。九维观测由七条射线距离、归一化速度、航向误差组成。七条射线覆盖车头前方和两侧,最大测距 8 米,超过 8 米按 8 米算。速度除以赛道最高速度,航向误差用角度除以 90 度,裁剪到 -1 到 1。所有归一化都在适配层完成,训练和试跑共用同一套逻辑,避免训练时输入 0~1、试跑时输入 0~8 这种翻车现场。
2.2 游戏环境接口:固定时间步与九维观测向量
接口只暴露三个方法:reset(seed)、step(action_id)、close()。step返回(obs, reward, done, info),info里放碰撞标志和本圈进度。决策间隔固定为 4 个渲染帧,也就是网络每 4 帧出一次动作,中间保持不动。这样做有两个原因:一是游戏帧率波动时,决策频率不会跟着乱跳;二是行为克隆的样本不会高度重复,训练时 batch 内的相关性更低。动作保持还能减少赛车在直道上左右抖动。
from dataclasses import dataclass from typing import Protocol import numpy as np @dataclass class Observation: rays: np.ndarray # shape=(7,), 单位: 米, 已裁剪到[0, 8] speed: float # 归一化速度, 0~1 heading_error: float # 车头与赛道切线的夹角, 归一化到[-1, 1] class GameAdapter(Protocol): def reset(self, seed: int) -> Observation: ... def step(self, action_id: int) -> tuple[Observation, float, bool, dict]: ... def close(self) -> None: ... ACTION_NAMES = ["left", "right", "straight", "throttle", "brake"]这段协议看起来简单,关键是step内部要保证四件事:动作执行固定帧数;碰撞后立即置done=True;奖励用“前进距离增量 + 速度奖励 - 碰撞惩罚”计算;info里保留碰撞和越界标志。奖励不必设计得很复杂,行为克隆阶段甚至可以把奖励只当评估信号,训练标签仍然来自规则控制器。射线距离在适配层裁剪,速度在适配层归一化,航向误差在适配层换算,训练代码拿到的永远是同一量纲的九维向量。
2.3 配置与目录组织:训练和试跑共用一套参数
目录按“环境、策略、数据、配置、检查点”分开,训练脚本和试跑脚本都从同一个 JSON 读参数。这样改隐藏层宽度或学习率时,不会出现训练用 32、试跑用 16 的低级错误。
project/ env/ adapter.py track.py policy/ mlp.py train.py evaluate.py data/ demos.npz configs/ default.json checkpoints/ mlp.npz{ "obs_dim": 9, "hidden_dim": 32, "action_dim": 5, "decision_interval": 4, "learning_rate": 0.003, "batch_size": 64, "epochs": 120, "max_range": 8.0 }obs_dim固定为 9,改传感器数量时这里和适配层一起改;hidden_dim从 16 到 64 都常见,32 是课设里比较折中的值;decision_interval控制动作保持帧数,赛道窄、车速快时降到 2,赛道宽、车速慢时升到 6;learning_rate从 0.003 起步,出现 loss 变 NaN 就减半;batch_size64 适合几千到几万条演示样本。配置里不要塞游戏窗口尺寸、颜色这些渲染参数,训练层用不到,塞进去只会让配置越来越难读。
3. 神经网络核心实现:手写 9-32-5 MLP 的前向与反向
自己实现神经网络,先定边界:只依赖 NumPy,网络做成两层 MLP,输入 9 维,隐藏层 32 个 ReLU 单元,输出 5 类动作概率。隐藏层太少,赛车在弯道里学不会组合判断;隐藏层太多,几千条演示样本容易过拟合,训练时间也拉长。激活函数用 ReLU,输出层配 softmax,损失用交叉熵。权重初始化用 He 初始化,适合 ReLU 的方差需求。优化器用带动量的 SGD,加全局梯度裁剪。整套代码不到一百行,但每一行都能对应到梯度公式,排查时不会把它当黑匣子。
3.1 网络结构与初始化:为什么是 9-32-5
输入 9 维分别对应七条射线、速度、航向误差。输出 5 维对应左转、右转、直行、加速、刹车。隐藏层 32 个单元,参数量是9×32 + 32 + 32×5 + 5 = 485,对课设规模刚好:足够拟合“前方变窄就转、航向偏了就回正”这类规则,又不至于在几千条样本上把训练集背下来。初始化不能全零,全零会让同一层的神经元对称,反向传播时梯度也一样,网络永远学不出差异。He 初始化把权重方差设成2 / 输入维度,ReLU 负半区被截断后,前向方差不会被压得太小。
import numpy as np class MLP: def __init__(self, obs_dim=9, hidden_dim=32, action_dim=5, lr=3e-3, seed=42): rng = np.random.default_rng(seed) # He 初始化,适合 ReLU self.W1 = rng.normal(0, np.sqrt(2.0 / obs_dim), (obs_dim, hidden_dim)) self.b1 = np.zeros((1, hidden_dim)) self.W2 = rng.normal(0, np.sqrt(2.0 / hidden_dim), (hidden_dim, action_dim)) self.b2 = np.zeros((1, action_dim)) self.lr = lr # 动量缓存 self.vW1 = np.zeros_like(self.W1) self.vb1 = np.zeros_like(self.b1) self.vW2 = np.zeros_like(self.W2) self.vb2 = np.zeros_like(self.b2)参数seed固定后,同一份数据两次训练的结果才能对齐。lr是初始学习率,后面可以在训练循环里做衰减。W1的形状是(9, 32),W2是(32, 5),偏置按行向量存,前向时直接广播。动量缓存和参数同形,更新时不需要再分配内存。这里不引入 bias 初始化成小随机数,是因为 ReLU 网络里偏置全零已经够用,随机偏置反而增加一个要调的参数。
3.2 前向传播与损失:softmax 配交叉熵
前向传播分三步:线性变换、ReLU、再线性变换得到 logits,最后 softmax 成概率。softmax 之前减去每行最大值,是为了防止exp溢出,这不是玄学,是数值计算里的常规操作。交叉熵损失把“正确动作的概率”取负对数,预测越接近 1,损失越小。反向传播时,softmax 加交叉熵的梯度有一个很干净的形式:probs - one_hot(label),再除以 batch 大小。这个形式比先对 softmax 求导再对交叉熵求导少很多中间项,也不容易写错符号。
def forward(self, x): self.x = x self.z1 = x @ self.W1 + self.b1 self.h = np.maximum(0.0, self.z1) logits = self.h @ self.W2 + self.b2 # 减最大值,避免 exp 溢出 logits = logits - logits.max(axis=1, keepdims=True) exp = np.exp(logits) self.probs = exp / exp.sum(axis=1, keepdims=True) return self.probsx是(batch, 9),z1是(batch, 32),h是 ReLU 后的激活,logits是(batch, 5)。keepdims=True保证减最大值时按行广播,不会把 batch 维度弄丢。返回值probs可以直接给argmax做推理,也可以留给backward算梯度。验证时调用forward会覆盖self.x、self.z1、self.h这些缓存,所以训练循环里每次backward之前必须重新forward一次,不能拿上一次的缓存接着用。
3.3 反向传播与参数更新:梯度裁剪和动量 SGD
反向传播先算输出层梯度,再传到隐藏层。dz2是probs复制一份,把正确类别的位置减 1,再除以 batch。gW2用隐藏层激活的转置乘dz2,gb2按列求和。传到隐藏层的梯度是dz2 @ W2.T,再乘 ReLU 的导数:z1 > 0的位置保留,否则置零。gW1和gb1同理。参数更新用动量 SGD,动量系数 0.9,让更新方向带一点惯性,减少 batch 之间的抖动。梯度裁剪按全局范数做,阈值 1.0,超过就整体缩小,避免个别样本把参数推飞。
def backward(self, y): n = y.shape[0] dz2 = self.probs.copy() dz2[np.arange(n), y] -= 1.0 dz2 /= n self.gW2 = self.h.T @ dz2 self.gb2 = dz2.sum(axis=0, keepdims=True) dh = dz2 @ self.W2.T dz1 = dh * (self.z1 > 0.0) self.gW1 = self.x.T @ dz1 self.gb1 = dz1.sum(axis=0, keepdims=True) def step(self, clip=1.0, momentum=0.9): grads = [self.gW1, self.gb1, self.gW2, self.gb2] # 按全局范数裁剪,防止梯度爆炸 norm = np.sqrt(sum((g * g).sum() for g in grads)) scale = min(1.0, clip / (norm + 1e-8)) for g in grads: g *= scale self.vW1 = momentum * self.vW1 - self.lr * self.gW1 self.W1 += self.vW1 self.vb1 = momentum * self.vb1 - self.lr * self.gb1 self.b1 += self.vb1 self.vW2 = momentum * self.vW2 - self.lr * self.gW2 self.W2 += self.vW2 self.vb2 = momentum * self.vb2 - self.lr * self.gb2 self.b2 += self.vb2y是整型标签,形状(batch,),np.arange(n)用来定位每个样本的正确类别。dz2除以n之后,gW2和gW1已经是平均梯度,不需要再除一次。clip设 1.0 是经验值,如果训练初期 loss 直接变 NaN,先降到 0.5 再试。momentum设 0.9,想更平滑可以到 0.95,但不要和过大学习率一起用。写完这段代码,建议做一次梯度检查:取一个 batch,扰动W1[0,0]一个很小的量,比较数值梯度和解析梯度的相对误差,误差在1e-4量级才算通过。这个检查做一次,后面调参能少很多血泪经验。
4. 驾驶策略训练:从规则演示到行为克隆的闭环
训练路线选行为克隆,不直接上强化学习。原因很实际:赛车游戏里奖励稀疏,撞墙才给负分,随机探索很难碰到终点;课设和毕设的周期又短,先让网络学会“像规则控制器那样开”,再谈超越规则。演示数据由规则控制器生成:前方射线太近就转向,航向误差偏了就回正,否则加速。规则控制器不需要很聪明,只要覆盖直道、弯道、贴墙、低速这几种场景,网络就能学到可用的动作映射。采集时每条 episode 随机起点,跑完一圈或撞墙结束,保存(obs, action)对。
4.1 演示数据采集:规则控制器生成动作标签
规则控制器用七条射线里的中间三条判断正前方,左右各两条判断两侧。正前方小于 2.2 米时,比较左右最小距离,哪边宽往哪边转;航向误差大于 0.15 时左转,小于 -0.15 时右转;其余情况加速。这个规则很粗糙,但标签一致性高,不会出现同一状态下动作来回跳。采集时动作 id 和网络输出维度对齐:0 左转、1 右转、2 直行、3 加速、4 刹车。刹车样本在规则控制器里很少出现,可以单独加一个“前方极近且速度高”的分支补一些,否则网络学不到刹车。
import numpy as np def rule_policy(obs): # 规则:正前方和两侧射线过近时优先转向,否则加速 front = obs.rays[3] left = obs.rays[:3].min() right = obs.rays[4:].min() if front < 2.2: if left > right: return 0 # 左转 return 1 # 右转 if obs.heading_error > 0.15: return 0 if obs.heading_error < -0.15: return 1 return 3 # 加速 def collect_demo(env, episodes=60, seed=0): X, y = [], [] for ep in range(episodes): obs = env.reset(seed=seed + ep) done = False while not done: a = rule_policy(obs) X.append([*obs.rays, obs.speed, obs.heading_error]) y.append(a) obs, _, done, _ = env.step(a) X = np.asarray(X, dtype=np.float32) y = np.asarray(y, dtype=np.int64) np.savez_compressed("data/demos.npz", X=X, y=y) return X, yepisodes=60是起步量,一条 episode 按 300 个决策步算,大约 1.8 万条样本。样本太少,网络会把直行学成万能动作;样本太多,采集时间拉长,收益递减。seed + ep保证每条 episode 起点不同,但整批数据可复现。X里直接拼九维向量,顺序必须是射线、速度、航向误差,训练和试跑不能变。np.savez_compressed存成 npz,后面训练脚本直接读,不用每次重新跑游戏。采集完先看类别分布:直行和加速占大头是正常的,如果左转、右转不到 10%,就补采弯道起点或把规则控制器的转向阈值调宽一点。
4.2 训练循环:打乱、分批、验证与保存
训练循环做四件事:按 80/20 划分训练集和验证集;每个 epoch 打乱训练集;按 batch 前向、反向、更新;每 10 个 epoch 在验证集上算准确率。验证准确率只是离线指标,不能代替实跑,但它能快速暴露“标签错位”“输入没归一化”这类问题。如果训练 loss 一直不降,先查数据;如果训练 loss 降、验证 loss 升,说明过拟合,减隐藏层或加样本。保存参数时把W1、b1、W2、b2一起存,试跑脚本只加载这四个数组。
from policy.mlp import MLP import numpy as np def train(model, X, y, epochs=120, batch_size=64, seed=42): rng = np.random.default_rng(seed) idx = rng.permutation(len(X)) split = int(len(X) * 0.8) tr, va = idx[:split], idx[split:] for epoch in range(epochs): rng.shuffle(tr) for start in range(0, len(tr), batch_size): batch = tr[start:start + batch_size] model.forward(X[batch]) model.backward(y[batch]) model.step(clip=1.0) probs = model.forward(X[va]) acc = (probs.argmax(axis=1) == y[va]).mean() if epoch % 10 == 0: print(f"epoch={epoch} val_acc={acc:.3f}")rng.shuffle(tr)只打乱索引,不复制数据,内存占用小。batch是索引数组,X[batch]取出来才是(64, 9)的矩阵。model.forward在验证时也会覆盖缓存,但训练循环下一轮会重新前向,不影响更新。epochs=120对 1.8 万条样本通常够用,验证准确率在 80 个 epoch 后趋于平缓就可以停。保存参数用下面这段:
np.savez("checkpoints/mlp.npz", W1=model.W1, b1=model.b1, W2=model.W2, b2=model.b2)加载时用np.load读回四个数组,赋值给同形状的模型,不要重新初始化。checkpoints目录里同时存一份配置 JSON 的副本,后面试跑时按配置重建模型结构,避免“参数形状对不上”的报错。
4.3 在线试跑与失败样本回流
离线准确率高不代表赛车能跑完一圈。试跑阶段只让网络决策,固定 10 条赛道种子,每条跑 3 次,记录圈速、碰撞次数、平均速度、动作切换次数。发现撞墙或原地打转的片段,把当时的观测和规则控制器的动作补进训练集,再重训一轮。这就是简化版的 DAgger:网络跑到它没见过的状态,规则控制器给一个正确动作,数据分布慢慢覆盖网络自己的错误状态。回流样本不要太多,一次加 500 到 1000 条,和原数据混在一起打乱,否则网络会偏向新样本。
试跑时还要看动作切换频率。如果网络在直道上每 4 帧换一次左右转,车会画龙,圈速一定差。解决方式不是改网络结构,而是加动作保持:连续 3 次决策相同才允许切换,或者对输出概率做滑动平均。动作平滑度用“相邻决策动作不同的比例”衡量,比例高于 30% 就说明策略在抖。这个指标在验收时比离线准确率更有说服力。
5. 避坑与排查:赛车原地打转和训练不收敛的五个现场
这套方案跑通不难,难的是出问题时知道先看哪里。下面五个现场是项目里最容易翻车的地方,每条按“现象、原因、解决”写,照着排查能省掉大量盲调参数的时间。
5.1 损失下降但赛车不跑:先查标签和归一化
现象:训练 loss 从 1.6 降到 0.2,验证准确率 0.92,实跑时赛车直直撞墙,或者一直原地转圈。原因通常有两个:训练时输入是归一化后的 0~1,试跑时忘了归一化,射线还是 0~8 米;或者演示数据里直行样本占 80%,网络学会“永远直行”就能拿高准确率,但直行在弯道里等于撞墙。解决:在适配层统一做归一化,训练和试跑共用同一个Observation构造函数;打印训练集和试跑第一帧的九维向量,逐维对比;按类别给损失加权,转向类权重调高,直行类权重调低;补采弯道和贴墙样本,让类别分布别太偏。
5.2 赛车贴墙抖动:动作延迟与决策频率错位
现象:赛车贴着墙走,左右动作高频切换,车身来回摆,圈速比规则控制器还慢。原因:决策间隔太短,网络每帧都在改动作;或者游戏执行动作有延迟,网络看到的是旧状态,输出的动作作用在新状态上;再或者输出概率接近 0.5,argmax在两类之间反复横跳。解决:把decision_interval从 1 调到 4,动作保持几帧再决策;在适配层记录动作下发时刻和实际生效时刻,确认没有延迟;对连续三次输出做多数投票,或者对概率做指数滑动平均;训练时加一点标签平滑,让网络不要过度自信。
5.3 换赛道就翻车:数据分布太窄
现象:训练赛道能跑完整圈,换一条宽窄不同的赛道,第一弯就撞。原因:演示数据只来自一条赛道,射线距离分布、弯道曲率、起点位置都很单一,网络记住了赛道特征而不是“看见墙就转”。解决:采集时随机化赛道宽度、摩擦系数、起点位置和初始速度;训练集和验证集按赛道划分,不要按样本随机划分,否则验证集和训练集太像,看不出泛化问题;保留一条完全没见过的赛道做最终测试;如果射线最大距离改了,输入归一化的分母也要跟着改。
5.4 梯度爆炸与全零输出:学习率和初始化
现象:训练几个 batch 后 loss 变成 NaN,或者网络输出五个动作概率完全一样,argmax永远返回 0。原因:学习率太大、没有梯度裁剪、He 初始化写错、ReLU 死亡。解决:先把学习率减半,再加全局梯度裁剪,阈值 1.0;检查W1初始化是不是用了sqrt(2/obs_dim),不要把obs_dim和hidden_dim写反;打印每层激活的均值和零值比例,如果隐藏层大部分是 0,说明 ReLU 死亡,换 LeakyReLU 或减小学习率;检查输入里有没有 NaN,射线距离除零会产生 inf。
5.5 结果无法复现:随机种子和训练记录
现象:同一份数据、同一套参数,两次训练出来的模型一个能跑圈,一个撞墙。原因:NumPy 随机种子没固定、数据打乱顺序不同、游戏起点随机、试跑时忘了加载同一份检查点。解决:default_rng(seed)固定种子,训练脚本开头打印种子;数据划分和打乱都用同一个 rng;游戏reset接收种子,试跑用固定种子列表;每次训练保存配置 JSON、数据文件哈希和检查点,试跑前核对这三样。做到这一步,答辩现场才能用固定种子复现演示,不会出现“刚才还能跑”的尴尬。
| 排查项 | 先看什么 | 常见结论 |
|---|---|---|
| 损失不降 | 输入归一化、标签顺序 | 训练和试跑量纲不一致 |
| 准确率高但撞墙 | 类别分布、验证集划分 | 直行样本过多或验证集泄漏 |
| 动作抖动 | 决策间隔、输出概率 | 动作保持太短或概率接近 0.5 |
| 换道翻车 | 赛道随机化、射线范围 | 数据分布太窄 |
| loss 变 NaN | 学习率、梯度范数 | 学习率过大或未裁剪 |
6. 验收与答辩:用圈速、碰撞率和动作平滑度说话
验收不要只报离线准确率。准备 10 条固定种子赛道,每条跑 3 次,记录圈速中位数、碰撞率、平均速度和动作切换比例。圈速对比规则控制器和网络策略,碰撞率按“碰撞 episode 数 / 总 episode 数”算,动作切换比例高于 30% 就说明策略在抖。答辩时先讲三层架构和数据流,再现场跑固定种子演示,最后展示一次失败案例和排查记录。评委更愿意看到你知道哪里会坏、怎么定位,而不是一个只能跑一次的演示。
| 指标 | 采集方式 | 参考合格线 |
|---|---|---|
| 圈速 | 10 条赛道各跑 3 次取中位数 | 不慢于规则控制器 20% |
| 碰撞率 | 碰撞 episode / 总 episode | 低于 20% |
| 动作切换比例 | 相邻决策动作不同的比例 | 低于 30% |
| 离线准确率 | 验证集 argmax 对比标签 | 高于 85% |
| 单帧推理耗时 | 前向传播 100 次取平均 | 低于 5 ms |
我每次交付前都强制跑一遍“固定种子 + 新赛道 + 长时试跑”三件套,避免现场翻车。这套方案的适配层、MLP、训练循环和评估脚本都按上面的模块拆好了,你要复现时从适配层和mlp.py两个文件开始改,剩下的训练流程可以直接套。希望帮到你。
本文还有配套的精品资源,点击获取