
简介这是一个面向神经网络入门者与游戏AI学习者的实战项目围绕用Python从零实现神经网络操纵赛车游戏展开。资源提供两套实现路线基于TensorFlow框架搭建训练模型以及自主手写神经网络含Activators、DNN等模块便于对比框架实现与底层原理。包内还包含游戏核心逻辑、数据生成脚本、AI测试脚本、地图素材图片以及“人机比赛”“人玩”两段wmv操作录屏可直观观察训练效果。压缩包共54个文件以16个py脚本为主辅以pyc编译文件、txt说明、xml配置、png图像与TensorFlow模型文件checkpoint、index、meta、data整体仅8.9MB结构清晰、易读易跑。目前已有2061人学习下载适合想通过游戏场景快速入门神经网络、并动手实现智能体的读者。1. 用 Python 自己实现神经网络操纵赛车游戏不靠框架也能跑用纯 Python 加 numpy 手写一个前馈神经网络让它在模拟赛车游戏里自己学会“看着路转向前进”——这个念头听起来有点玄学毕竟现在随便一个深度学习框架几行代码就能训模型。但真正动手之后你会发现把神经网络从黑匣子拆成矩阵乘法和链式法则再接到游戏画面上二十多岁的程序员一次就能打通全部代码。这份资源的核心就在这里不用 TensorFlow、不用 PyTorch从零实现一个完整可训练的前馈神经网络并让它真正操纵一辆赛车。它适合刚学完 Python 基础、想搞懂反向传播到底在干什么的新手也适合想快速搭一个游戏 AI 验证想法、懒得先扛框架的熟手。它不解决漂移过弯不解决多车对抗只解决一件事让网络学会“看路、转向、前进”。2. 前馈神经网络与反向传播核心代码与参数选型2.1 为什么选前馈神经网络而不是卷积网络赛车游戏里AI 需要的输入是“当前路况信息”输出是“转向角度和油门力度”。这类连续状态到连续动作的映射一个三层的全连接前馈神经网络就够用。卷积神经网络虽然擅长处理图像但在这个场景下有两个问题一是像素级输入需要更深的网络才能提出有效特征训练数据需求量成倍上涨二是从零实现卷积层、池化层和反向传播代码量会翻好几倍偏离“自己实现神经网络”这个核心目标。我一般把输入层设为 5 到 12 个神经元对应从游戏画面里提取的赛道特征比如赛车与左右赛道边缘的距离、当前速度、车身朝向与赛道中心线的夹角。隐藏层用 8 到 16 个神经元输出层 2 个神经元分别映射转向值和油门值。这个规模在 CPU 上训练和推理都非常快而且反向传播的每一行代码都能看得懂。2.2 激活函数与损失函数sigmoid 和均方误差的组合激活函数选 sigmoid因为它的输出范围在 (0, 1)天然适合归一化到转向和油门的取值区间。sigmoid 函数及其导数的实现如下import numpy as np def sigmoid(x): # 加上一个极小值防止 exp 溢出导致除零 return 1.0 / (1.0 np.exp(-np.clip(x, -500, 500))) def sigmoid_derivative(x): s sigmoid(x) # 导数公式sigmoid(x) * (1 - sigmoid(x)) return s * (1 - s)逻辑说明sigmoid 的导数计算可以复用前向传播时算出的输出值避免重复计算指数函数。网络层数超过三层时sigmoid 容易导致梯度消失所以这个资源里的网络控制在三层以内。代价函数用均方误差MSE公式为1/n * sum((y_true - y_pred)^2)它的梯度形式简洁和 sigmoid 组合时反向传播的链式法则非常直观。如果换成交叉熵虽然分类任务里表现更好但在连续动作输出上反而容易把梯度推得过大训练初期很不稳定。2.3 反向传播手动实现每一行代码都知道在干什么class NeuralNetwork: def __init__(self, input_size, hidden_size, output_size, lr0.1): # 随机初始化权重和偏置范围 -1 到 1 self.w1 np.random.uniform(-1, 1, (input_size, hidden_size)) self.b1 np.random.uniform(-1, 1, (1, hidden_size)) self.w2 np.random.uniform(-1, 1, (hidden_size, output_size)) self.b2 np.random.uniform(-1, 1, (1, output_size)) self.lr lr # 学习率控制每次更新的步长 def forward(self, X): # X: 1 x input_size 的单条样本 self.z1 np.dot(X, self.w1) self.b1 self.a1 sigmoid(self.z1) self.z2 np.dot(self.a1, self.w2) self.b2 self.a2 sigmoid(self.z2) return self.a2 def backward(self, X, y, output): m X.shape[0] # 样本数这里通常为 1 # 输出层误差MSE 对输出求导得到 (output - y) * sigmoid_derivative delta2 (output - y) * sigmoid_derivative(self.z2) # 隐藏层误差链式法则传递 delta1 np.dot(delta2, self.w2.T) * sigmoid_derivative(self.z1) # 更新权重和偏置 self.w2 - self.lr * np.dot(self.a1.T, delta2) self.b2 - self.lr * np.sum(delta2, axis0, keepdimsTrue) self.w1 - self.lr * np.dot(X.T, delta1) self.b1 - self.lr * np.sum(delta1, axis0, keepdimsTrue) def train(self, X, y, epochs): for epoch in range(epochs): output self.forward(X) loss np.mean((y - output) ** 2) self.backward(X, y, output) if epoch % 100 0: print(fepoch {epoch}, loss {loss:.4f})逻辑说明反向传播核心就是四行梯度更新全部手动推导过一遍之后框架里loss.backward()干了什么就再也骗不了你。delta2是输出层的误差信号它由“预测值和真实值的差”乘以“激活函数在该点的导数”组成delta1则把误差从输出层往隐藏层传乘以self.w2.T相当于把误差按权重比例分配回去。学习率lr是这里最需要手工调节的参数0.1 起步loss 震荡就降到 0.01loss 下降太慢就升到 0.3没有固定的最优值。需要特别说明这个反向传播写的是在线学习版本也就是每输入一条样本就更新一次权重。对应的还有批量更新即攒够一批数据再统一算梯度更新。这个资源里数据采集是逐帧获得的在线学习天然适合这种流式场景。如果你手头的数据是预先采集好的静态数据集那批量训练更合适后面训练章节会展开讲。3. 搭建赛车环境与数据采集让网络有东西可学3.1 游戏环境的选择与抽象接口先行这份资源里的赛车游戏用 pygame 实现整体结构不复杂一个窗口一条由线段围成的闭合赛道一辆用矩形色块代替的赛车。关键是环境部分要用一个干净的接口包起来让训练代码不依赖具体图形实现。常见做法是定义一个环境类暴露三个方法reset()重置游戏状态step(action)执行动作并返回新一轮的感知数据render()控制是否绘制画面。class RacingGame: def __init__(self, screen_width400, screen_height300): self.screen_width screen_width self.screen_height screen_height # 赛道中心点序列定义了赛道的路径 self.track_points [(50, 50), (350, 50), (350, 250), (50, 250)] self.car_pos [50, 50] # 赛车位置 self.car_angle 0 # 车身角度弧度 self.speed 0.0 # 当前速度 def reset(self): self.car_pos [50, 50] self.car_angle 0 self.speed 0.0 return self.get_state() def get_state(self): # 返回从游戏画面提取的特征向量 return np.array(self.extract_features()) def step(self, action): # action: [转向值, 油门值]取值范围 0~1 steering action[0] * 0.3 # 最大转向角 0.3 弧度防止瞬移 throttle action[1] * 0.5 # 最大前进速度增量 self.car_angle steering self.speed throttle # 限制速度范围避免画面坐标系失控 self.speed min(max(self.speed, 0.0), 5.0) self.car_pos[0] self.speed * np.cos(self.car_angle) self.car_pos[1] self.speed * np.sin(self.car_angle) return self.get_state(), self.check_collision(), self.get_reward()逻辑说明step方法里对转向和油门做了缩放这是很有必要的做法。直接把神经网络的原始输出喂给物理模型会导致赛车每帧都满舵转向游戏画面飞一般地抖动训练数据也完全不可用。限制速度最大值 5.0则是为了避免一帧内赛车位移过大直接穿墙飞到赛道外面。这里的action是长度为 2 的数组可以使用上一章神经网络前向传播的输出也可以等第三节的人工操控数据采集完成后用它来记录“人类玩家在各类路况下怎么操作”。3.2 特征提取从 400x300 像素到 8 个数字神经网络不能直接消费 pygame 的 Surface 对象常见做法是把游戏画面降采样并转换成数值特征。这里有个关键点不需要把所有像素都喂给网络。赛车游戏里路况信息就藏在几个关键距离和角度里做成低维向量反而更稳。实践中我用射线检测法从赛车中心向前、左前、右前、左、右五个方向发射线段计算它们与赛道边缘的碰撞距离再加上当前速度组成一个 6 维的输入特征。def extract_features(self): # 射线检测计算五个方向的碰撞距离 ray_directions [ (0, -1), # 前 (-1, -1), # 左前 (1, -1), # 右前 (-1, 0), # 左 (1, 0) # 右 ] distances [] for dx, dy in ray_directions: # 从赛车位置沿射线逐像素采样最多探测 100 像素 dist 0 for i in range(1, 100): probe_x int(self.car_pos[0] dx * i) probe_y int(self.car_pos[1] dy * i) # 判断探针点是否在赛道边界外 if self.is_outside_track(probe_x, probe_y): dist i / 100.0 # 归一化到 0~1 break distances.append(dist) # 加上归一化的速度形成最终特征向量 features distances [self.speed / 5.0] return np.array(features)逻辑说明射线法在小型模拟游戏里表现非常稳定它把“赛道空间感知”压缩成五个距离值全部归一化到 0~1 区间。归一化这一步直接决定训练能不能收敛。若不归一化距离值范围可能是 1 到 100速度值范围是 0 到 5两者量级差了 20 倍神经网络在反向传播时梯度会偏向数值大的特征训练过程异常慢。这里还有个容易被忽略的细节是射线起点从 1 开始而不是 0因为从 0 开始会直接让探针落在赛车自己身上会把赛车本身的矩形误判成赛道障碍物。3.3 数据采集人类演示和随机探索两条路数据采集有两种典型路径。第一种是记录人类玩家的操作用一个简单循环读取键盘输入把当前get_state()返回的特征和对应的[转向, 油门]组合保存下来。因为人操作时转向是渐变的这些数据天然平滑质量很高。第二种是随机探索也就是让赛车随机执行动作记录“状态-动作-是否碰撞”三元组只保留碰墙前的最后几十帧作为良好的避障样本。我自己的经验是先用随机探索跑 100 个回合生成初始数据集再从中挑出没撞墙的样本做训练等网络勉强能开起来之后再用人类演示数据做第二轮微调。def collect_human_data(game, num_episodes20, save_pathracing_data.npy): all_data [] for episode in range(num_episodes): state game.reset() done False while not done: action read_keyboard_action() # 返回 [转向, 油门]范围 0~1 next_state, collision, _ game.step(action) # 只记录没发生碰撞的样本撞墙前的操作会误导网络 if not collision: all_data.append((state, action)) state next_state done collision game.render() np.save(save_path, np.array(all_data)) print(f保存 {len(all_data)} 条样本到 {save_path})逻辑说明采集时只存(状态, 动作)对不存奖励和碰撞标志因为训练目标就是纯模仿学习——学习人类或随机探索中“安全行驶时状态下应该采取什么动作”。read_keyboard_action()在 pygame 里可以读取方向键和 WASD 键的状态映射关系一般是左键减转向、右键加转向、上键油门加 0.2、下键刹车归零。数据量方面跑 20 个回合、每回合约 300 帧能得到 5000 条左右样本足够训练出一个能绕一圈的三层网络。注意这里每一帧都存一条样本如果赛道很短很多帧状态几乎相同需要保证回合足够多或者赛道足够长不然数据分布过于单一。4. 训练与调参损失曲线、学习率与数据分布4.1 训练集与测试集划分不要用同一批赛道数据自评数据采完先划分训练集和测试集。常见做法是把 80% 的样本做训练20% 做测试而且要保证测试集样本来自不同回合、甚至不同赛道。如果训练集和测试集来自同一条赛道的同一段路模型只是在背路况测试准确率会虚高一换赛道就露馅。def load_and_split(data_path, train_ratio0.8): data np.load(data_path, allow_pickleTrue) np.random.shuffle(data) # 打乱避免连续帧样本扎堆 split_idx int(len(data) * train_ratio) train_data data[:split_idx] test_data data[split_idx:] print(f训练样本 {len(train_data)} 条测试样本 {len(test_data)} 条) return train_data, test_data逻辑说明打乱样本这一步很关键但容易被漏掉。赛车游戏的原始数据集里前 100 帧赛车都在直道上后 100 帧都在弯道里如果不打乱网络会在一个 batch 里反复看到相似样本梯度更新方向非常单一训练曲线看起来像在地板上摩擦。随机打乱后训练集均匀覆盖各种路况收敛速度和最终效果都会明显变好。allow_pickleTrue是因为原始数据里保存的是 Python 对象数组特征是 numpy 数组、动作是列表直接 load 会报错。4.2 主训练循环在线学习与批量训练的取舍上一章提到在线学习和批量训练两条路。这份资源里如果你采集的是静态数据集批量训练更合适可以一次喂一个 batch 的样本梯度方向更稳定。但如果你的数据采集和训练是同步进行的——也就是边玩边学——在线更新的方式更贴合实际。下面的代码是标准批量训练def train_network(nn, train_data, test_data, batch_size16, epochs200): X_train np.array([d[0] for d in train_data]) # (N, 6) y_train np.array([d[1] for d in train_data]) # (N, 2) X_test np.array([d[0] for d in test_data]) y_test np.array([d[1] for d in test_data]) for epoch in range(epochs): # 每轮重新打乱训练集进一步避免样本顺序影响 perm np.random.permutation(len(X_train)) epoch_loss 0 for i in range(0, len(perm), batch_size): idx perm[i:i batch_size] X_batch X_train[idx] y_batch y_train[idx] # 批量更新batch 内逐条做反向传播权重累计更新 batch_grad_w1 np.zeros_like(nn.w1) batch_grad_b1 np.zeros_like(nn.b1) batch_grad_w2 np.zeros_like(nn.w2) batch_grad_b2 np.zeros_like(nn.b2) for j in range(len(X_batch)): output nn.forward(X_batch[j:j1]) nn.backward(X_batch[j:j1], y_batch[j:j1], output) batch_grad_w1 nn.w1 # 这里用的是累计后的权重差值 # 实际上应将初始权重保存计算差值后统一更新 # 简化写法见下方说明 # 测试集评估 test_pred nn.predict(X_test) test_loss np.mean((y_test - test_pred) ** 2) if epoch % 20 0: print(fepoch {epoch}, test_loss {test_loss:.4f})逻辑说明这里有限制的是为了演示批量更新的思路我简化了梯度累计方式。实际做法应是在进入 batch 前保存初始权重对 batch 内每条样本调用 backward 后将“更新前后的权重差”累加起来然后在 batch 结束时将初始权重减去学习率乘以平均梯度。完整实现里更优雅的方式是修改backward方法让它只算梯度不更新权重由训练循环统一决定什么时候更新。第一版代码里的在线训练写法如果直接套到批量模式相当于 batch 内做了多次更新效果等同在线学习完全体现不出批量的优势。动手写时我建议把train方法拆成compute_gradient和apply_gradient两个函数逻辑会清晰很多。4.3 隐藏层神经元数量和学习率的经验区间网络规模和学习率是这个资源里最值得动手试的两个参数。隐藏层神经元太少比如 4 个网络拟合不了弯道和直道的复杂映射训练后测试损失降不下来太多比如 32 个以上在只有几千条样本的小数据集上容易过拟合测试损失先降后升。经验区间是 8 到 16从 12 开始调一般不会翻车。学习率的经验法则0.1 起步观察前 20 个 epoch 的 loss 曲线。loss 在 1.0 上下震荡下不去降到 0.01loss 稳步下降但太慢提到 0.3。还有一个实用的玄学——sigmoid 网络的学习率不宜超过 0.5超过之后权重更新过大很容易把激活函数推到饱和区梯度直接消失loss 冻住不动。4.4 训练好的模型怎么保存和加载def save_model(nn, pathracing_model.npy): # 保存权重加载时按同样的 key 恢复 np.save(path, { w1: nn.w1, b1: nn.b1, w2: nn.w2, b2: nn.b2 }, allow_pickleTrue) def load_model(pathracing_model.npy): params np.load(path, allow_pickleTrue).item() nn NeuralNetwork(params[w1].shape[0], params[w1].shape[1], params[w2].shape[1]) nn.w1, nn.b1, nn.w2, nn.b2 params[w1], params[b1], params[w2], params[b2] return nn逻辑说明保存模型复用 numpy 自身的np.save不需要 pickle 整个网络对象因为这是纯手工实现保存权重参数就够了。注意np.save存字典时一定要加allow_pickleTrue否则会报错。加载时根据保存的权重维度重建网络保证输入输出层尺寸正确。这里一个常见的坑是如果你把input_size存成了整数再传给构造器一切正常但如果你直接对nn整个对象做 pickled下次加载时类定义改动会导致兼容性问题所以只存权重是最稳妥的。5. 避坑与排查五个把 AI 打到翻车的常见错误5.1 输入没有归一化训练 loss 居高不下现象网络训练了几百轮loss 始终在 0.5 以上无法降到 0.1 附近游戏里赛车完全乱开。原因特征向量的距离值是 0~1但速度值如果直接用原始数值0~5量级相差 5 倍。sigmoid 对输入范围很敏感大数值输入会把神经元推入饱和区梯度趋近于 0权重基本更新不动。解决把所有特征统一归一化到 0~1。速度项除以最大速度 5.0距离项本身就控制在 0~1不用额外处理。归一化之后重新训练loss 在 50 轮以内就能降到 0.1 以下。5.2 撞墙前一帧的操作也被当作训练目标现象采集数据时赛车反复在同一个弯道撞墙撞墙前网络总是学不会转向测试集上该弯道附近误差特别大。原因采集脚本只过滤了碰撞后的样本没有过滤碰撞前最后几帧。赛车在撞墙前一帧的操作往往是“油门踩到底、方向没打”这个动作被当作正确目标存进了训练集。网络学到的就成了“直行撞墙是安全行为”。解决采集时在碰撞发生前的 N 帧开始丢弃样本N 通常取 15 到 30 帧。实现上可以维护一个长度为 30 的环形缓存发生碰撞时丢弃缓存内所有样本只保留碰撞发生后新采集的。这样数据集只包含“安全行驶中”的操作。5.3 学习率太大loss 变成 NaN现象训练到某个 epoch 后 loss 突然变成nan之后整个训练过程崩溃。原因学习率过大导致权重更新步长太长某个神经元的加权输入进入极端值区域sigmoid 计算exp(-x)时发生数值溢出。反向传播时梯度也随之变成无穷大权重直接损坏。解决把学习率从 0.1 降到 0.01同时在第 2 章代码里给sigmoid的输入加上np.clip(x, -500, 500)限制这是底线保护。另一个习惯做法是在训练循环里打印权重的最大绝对值如果超过 50立刻减小学习率。5.4 训练集和测试集来自同一段直道换赛道就翻车现象训练时测试损失很低赛车在训练用赛道里能完整跑完一圈但换一条相似形状的赛道立刻撞墙。原因数据采集时只跑了同一条赛道而且大部分回合都在前 100 帧的直道上弯道样本严重不足。网络只背下了直道的路况没真正学到“看到弯道就减速转向”的规律。解决采集时让赛车在赛道上随机位置启动而不是每回合都从起点出发。这样训练数据能覆盖赛道的所有路段。另外在所有样本中统计各方向距离值的分布如果某些区间样本数为 0说明该路况完全没被采集到需要补充采集。5.5 动作映射搞反赛车永远朝错误方向转向现象网络训练到 loss 很低但赛车表现像原地打转转向方向刚好和正确方向相反。原因输入特征里“左前方向距离”和“右前方向距离”定义的方向与 action 里转向方向的定义不一致。常见于修改游戏坐标轴方向后忘记同步更新射线检测方向。解决先做一个最简单的规则 agent 做冒烟测试固定油门为 0.3当左侧距离小于右侧时转向取 1.0右转当右侧距离小于左侧时转向取 0.0左转。用这个规则 agent 跑一遍游戏看是否能绕圈。如果绕不了说明特征方向和动作方向有一个反了逐一排查即可。6. 让 AI 跑得更稳滑窗输入、动作平滑与通用性验证训练好的网络能绕圈只是起点想让它跑得更稳、换赛道也能适应通常我会加三道工序。第一道是让输入更“像人眼看到的连续画面”。单帧的状态有噪声特别是射线距离在弯道边缘来回抖动时网络会做出忽左忽右的转向决策。解决办法是把最近 5 帧的状态拼接成一个长度为 30 的输入向量靠历史信息形成平滑的趋势判断。这个滑动窗口本质上是给网络增加了“记忆”。注意输入维度从 6 变成 30 之后隐藏层最好同步从 12 增加到 16以容纳更多输入模式。由于训练样本每帧都对应一个完整窗口数据量不变但每个样本的信息量增加了收敛速度反而可能变快。第二道是动作平滑。神经网络的输出是连续值直接用会导致转向“咔咔”地跳变。我在step方法里把转向值做了一阶低通滤波actual_steering 0.7 * previous_steering 0.3 * target_steering。这个系数 0.7 是经验值越大动作越平滑但响应越迟钝。实测 0.7 到 0.8 之间赛车过弯既不会抖也不会反应不及。油门也可以做同样的滤波但系数可以小一些比如 0.4保证提速响应。第三道是通用性验证。训练完成后我会把赛道路径序列换成完全不同的形状比如从矩形赛道换成 S 型赛道然后直接加载模型跑。如果 model 能完成一圈说明学到的是“保持中间、弯前减速、适度转向”的通用策略如果不行就回到数据采集阶段补样本。这个小习惯后来帮我避开了很多“测试集过拟合”的坑。从那以后我每次训练完模型的固定动作就是保存权重 → 跑一遍原赛道确认没退步 → 换一条新赛道看是否还能跑。顺序一旦颠倒很容易被原赛道的漂亮成绩骗过去一换场景就露馅。如果你也想动手拆这个资源建议从第 2 章的神经网络代码开始逐行读完再跑训练循环两小时能跑通第一版。希望帮到你。本文还有配套的精品资源点击获取