资讯动态

从零手搓神经网络:Python 操纵赛车游戏实战

发布时间:2026/10/20 1:55:21 来源:尧图企业网站定制
简介这份资源面向想入门神经网络与强化学习的Python开发者通过让神经网络自动操纵赛车游戏这一趣味场景解决从理论到实践缺少完整项目案例的问题。包内共54个文件以16个py源码、10个pyc编译文件、9个txt说明、7个xml配置及5张png素材为主另含2段wmv人机对战录像和TensorFlow模型检查点文件压缩包约8.9MB。项目分为「使用TensorFlow框架」与「自己实现神经网络」两条主线前者借助TensorFlow搭建训练模型后者从DNN、激活函数等底层组件手写网络并配有地图数据、游戏逻辑、数据生成与AI测试等模块目录结构清晰。已有2063人学习下载适合作为神经网络入门练手项目读者可获取完整赛车游戏源码、模型训练与测试脚本、演示视频及素材对照两条实现路线理解网络搭建、数据采集与调参排错思路。1. 从零手搓神经网络用 Python 操纵赛车游戏到底有多硬核很多人第一次听到「用 Python 自己实现神经网络操纵赛车游戏」脑子里浮现的是调个库、跑个预训练模型、看小车自己跑两圈就完事。真动手才发现最难的从来不是神经网络本身而是怎么让网络和游戏环境对上话——游戏画面怎么变成输入向量网络吐出来的输出怎么变成油门和方向盘中间这一层「翻译」才是血泪经验的集中地。这个项目就是干这件事的不依赖任何深度学习框架用纯 Python 从零实现一个前馈神经网络通过感知赛车前方的距离传感器数据实时输出转向、加速、刹车指令让车在赛道里自己跑起来。它适合两类人一类是刚学完反向传播公式、想找个能跑的东西验证理解的开发者另一类是做过游戏 AI、但一直用现成框架、想搞清楚底层矩阵运算到底怎么落地的人。整个项目代码量不大但麻雀虽小五脏俱全从网络结构定义、前向传播、损失计算到反向传播更新权重每一行都能对应到教科书上的公式。2. 项目架构拆解神经网络和游戏循环怎么接在一起2.1 整体模块划分与数据流这个项目的架构可以理解成三层游戏环境层、感知适配层、神经网络决策层。游戏环境层负责渲染赛道、更新车辆物理状态、提供传感器读数感知适配层把原始传感器数据归一化成网络能吃的输入向量神经网络决策层拿到输入后做一次前向传播输出三个动作值再被适配层翻译成游戏能理解的操控指令。整个数据流是单向的传感器 → 归一化 → 网络前向 → 动作映射 → 游戏执行 → 下一帧。常见做法是把游戏循环和网络推理放在同一个主循环里每帧调用一次网络。这样做的好处是逻辑清晰调试的时候可以在任意一帧打断点看输入输出。坏处是如果网络太大帧率会掉但对于这个项目来说一个隐藏层几十个神经元的网络单次前向传播在纯 Python 里也就几百微秒完全够用。我一般会把代码拆成这几个文件network.py放神经网络的核心实现game_env.py放游戏环境的封装sensors.py处理传感器数据的读取和归一化main.py是主循环入口。这样拆的好处是你想换游戏或者换网络结构只需要改对应的文件不用在一坨代码里翻来翻去。2.2 神经网络类的接口设计网络类的接口设计直接决定了后面好不好改。我见过不少人把前向传播和反向传播写在一个巨型函数里参数全靠全局变量传改一个学习率要翻三个文件。这个项目里网络类应该暴露这几个方法__init__接收每层的神经元数量forward接收输入向量返回输出向量backward接收目标值和学习率更新权重save和load负责权重持久化。import numpy as np class NeuralNetwork: def __init__(self, layers): # layers 是一个列表比如 [5, 8, 3] 表示输入 5 维、隐藏层 8 个神经元、输出 3 维 self.layers layers self.weights [] self.biases [] # 初始化权重和偏置使用小随机数避免对称性 for i in range(len(layers) - 1): w np.random.randn(layers[i], layers[i1]) * 0.1 b np.zeros((1, layers[i1])) self.weights.append(w) self.biases.append(b) def forward(self, x): # 逐层做线性变换 sigmoid 激活 self.activations [x] self.z_values [] for i in range(len(self.weights)): z np.dot(self.activations[-1], self.weights[i]) self.biases[i] self.z_values.append(z) a self.sigmoid(z) self.activations.append(a) return self.activations[-1] def sigmoid(self, z): # 防止溢出对输入做裁剪 z np.clip(z, -500, 500) return 1.0 / (1.0 np.exp(-z)) def sigmoid_derivative(self, a): # sigmoid 的导数用输出值表示 return a * (1 - a)这段代码里layers参数决定了网络拓扑比如[5, 8, 3]就是 5 个输入、8 个隐藏、3 个输出。权重初始化用randn * 0.1是为了打破对称性如果全初始化为零所有神经元学到的东西一模一样网络就废了。forward方法里把每一层的线性结果z和激活结果a都存下来是因为反向传播的时候要用。sigmoid里做clip是防止输入太大导致exp溢出这个坑我在早期版本里踩过训练到一半 loss 变成 nan查了半天才发现是数值溢出。2.3 反向传播的实现细节反向传播是这个项目里最容易写错的部分。核心思路是先算输出层的误差然后逐层往前传每一层根据误差算权重的梯度再更新权重。误差的定义是损失函数对输出的偏导如果用均方误差就是(预测值 - 真实值)。def backward(self, x, y, learning_rate): # 前向传播拿到每一层的激活值 output self.forward(x) # 计算输出层误差 error y - output # 从最后一层往前逐层计算梯度 for i in range(len(self.weights) - 1, -1, -1): # 当前层的 delta delta error * self.sigmoid_derivative(self.activations[i1]) # 计算权重梯度 grad_w np.dot(self.activations[i].T, delta) grad_b np.sum(delta, axis0, keepdimsTrue) # 更新权重和偏置 self.weights[i] learning_rate * grad_w self.biases[i] learning_rate * grad_b # 把误差往前传一层 error np.dot(delta, self.weights[i].T)这里有几个关键点。delta是当前层的误差项等于上一层的误差乘以激活函数的导数。grad_w是权重梯度等于当前层的输入转置乘以delta。更新权重的时候用的是而不是-因为这里的error定义是y - output梯度方向已经反过来了。如果写成-网络会越学越差。这个符号问题我见过至少三个人在群里问过都是因为误差定义和更新方向没对齐。learning_rate一般取 0.1 到 0.5 之间太大容易震荡太小收敛慢。对于这个赛车项目因为输入维度不高0.2 左右比较稳。2.4 游戏循环与传感器数据接入游戏循环这部分核心是把传感器数据喂给网络再把网络输出映射成操控指令。假设游戏提供 5 个距离传感器分别探测正前方、左前、右前、左侧、右侧的距离归一化到 0 到 1 之间。def game_loop(network, env): state env.reset() while not env.done: # 读取传感器数据并归一化 sensors env.get_sensors() # 返回 5 个距离值 max_range env.max_sensor_range inputs np.array([s / max_range for s in sensors]).reshape(1, -1) # 网络前向传播得到动作 outputs network.forward(inputs) # 输出三个值分别映射到转向、油门、刹车 steer (outputs[0][0] - 0.5) * 2.0 # 映射到 -1 到 1 throttle outputs[0][1] # 0 到 1 brake outputs[0][2] # 0 到 1 # 执行动作 state, reward, done env.step(steer, throttle, brake) return state这段代码里get_sensors返回的是原始距离值除以max_range做归一化是因为神经网络的输入最好在 0 到 1 之间否则 sigmoid 容易饱和。输出层三个神经元分别对应转向、油门、刹车转向做了从 0 到 1 到 -1 到 1 的映射因为游戏里转向通常是左右对称的。油门和刹车直接用 0 到 1 的值。如果游戏环境不提供传感器数据只有画面那就需要额外做图像处理把画面裁剪、灰度化、降采样成一个小向量。但那是另一个话题了这个项目默认游戏提供结构化传感器数据。3. 跑起来环境准备、训练流程与参数调优3.1 依赖安装与项目初始化这个项目对环境的依赖非常轻核心就是 NumPy。如果你用的是纯 Python 列表做矩阵运算连 NumPy 都可以省掉但性能会差很多。我建议还是装上 NumPy一行命令的事。pip install numpy游戏环境这块取决于你用的是哪个赛车游戏。如果是自己写的简易 2D 赛车模拟器那直接跑main.py就行。如果是接入现成的游戏可能需要额外的通信库比如通过 socket 或者共享内存传数据。这个项目默认是自带一个简易游戏环境的方便你先把网络跑通再考虑接真实游戏。项目初始化的时候先确认目录结构是这样的project/ ├── network.py # 神经网络实现 ├── game_env.py # 游戏环境封装 ├── sensors.py # 传感器数据处理 ├── main.py # 主循环入口 └── weights.npz # 训练好的权重文件可选如果你拿到的是压缩包解压后先看main.py里的入口函数确认游戏环境是自带的还是需要额外配置。3.2 训练数据的准备与网络训练这个项目有两种训练方式监督学习和强化学习。监督学习需要你手动跑几圈记录下传感器数据和对应的正确操控然后用这些数据训练网络。强化学习则不需要标注数据让车自己跑根据跑的距离或者是否撞墙给奖励用奖励信号来更新网络。监督学习的训练循环大概长这样# 假设 data 是一个列表每个元素是 (sensors, actions) network NeuralNetwork([5, 12, 3]) learning_rate 0.2 epochs 500 for epoch in range(epochs): total_loss 0 for sensors, actions in data: inputs np.array(sensors).reshape(1, -1) targets np.array(actions).reshape(1, -1) output network.forward(inputs) loss np.mean((targets - output) ** 2) total_loss loss network.backward(inputs, targets, learning_rate) if epoch % 50 0: print(fEpoch {epoch}, Loss: {total_loss / len(data):.4f})这里data是你收集的训练样本每个样本包含一组传感器读数和对应的操控指令。epochs是训练轮数500 轮对于这个小网络来说足够了。learning_rate设成 0.2如果你发现 loss 震荡得厉害降到 0.1 试试。每 50 轮打印一次 loss方便观察收敛情况。强化学习的训练循环不太一样不需要标注数据而是让车在环境里跑根据奖励更新网络。核心思路是用策略梯度网络输出动作的概率分布根据实际获得的奖励调整动作的概率。这部分代码稍微复杂一些但思路是一样的前向传播拿动作执行动作拿奖励反向传播更新权重。3.3 关键参数怎么调这个项目里能调的参数不多但每一个都影响很大。下面这张表是我自己调参时总结的供你参考。参数含义建议范围调参方向layers网络层数和每层神经元数[5, 8~16, 3]隐藏层神经元太少学不动太多容易过拟合learning_rate学习率0.1 ~ 0.5loss 震荡就调小收敛慢就调大epochs训练轮数300 ~ 1000看 loss 曲线平了就停max_range传感器最大探测距离根据赛道宽度定太大分辨率低太小看不到远处batch_size批量大小1 ~ 32小批量更新频繁但噪声大大批量稳定但慢隐藏层神经元数量是最需要试的。我一般从 8 个开始如果车在弯道总是反应不过来加到 12 或 16 试试。但别加太多纯 Python 的矩阵运算神经元数量翻倍训练时间大概翻三倍。学习率的影响也很直接。0.5 的时候 loss 曲线像心电图0.1 的时候收敛慢得让人想睡觉。0.2 到 0.3 之间通常比较舒服。3.4 训练效果怎么验证训练完了怎么知道网络好不好最直接的办法是让车跑几圈看它能不能完整跑完赛道不撞墙。但这样太主观我一般会看两个指标平均 loss 和完成率。平均 loss 在训练集上降到 0.01 以下说明网络拟合得差不多了。但 loss 低不代表车开得好因为训练数据里的操控不一定是最优的。所以还要看完成率让车跑 20 次统计有多少次能完整跑完一圈。完成率超过 80% 就算不错了。如果 loss 很低但完成率很差大概率是训练数据质量不行或者网络过拟合了。这时候可以试试减少隐藏层神经元或者增加训练数据的多样性。4. 避坑指南训练赛车 AI 时最容易翻车的几个地方4.1 现象车在原地打转或者直接撞墙原因传感器数据没有归一化或者归一化范围不对。如果传感器原始值范围是 0 到 200你直接喂给网络sigmoid 函数在输入大于 10 的时候基本就饱和了输出全是 1 或者全是 0网络根本学不到东西。解决确认所有输入都归一化到 0 到 1 之间。如果传感器有方向性比如左侧距离和右侧距离还要考虑是否需要做对称处理。我一般会在sensors.py里加一个normalize函数所有传感器数据都过一遍这个函数再喂给网络。4.2 现象训练 loss 一直不下降或者变成 nan原因学习率太大导致梯度爆炸或者 sigmoid 溢出。纯 Python 实现的时候如果输入值太大np.exp(-z)会溢出结果变成 inf 或者 nan。解决在 sigmoid 函数里加np.clip(z, -500, 500)防止溢出。学习率从 0.1 开始试如果 loss 还是震荡降到 0.05。另外检查一下权重初始化如果初始权重太大第一轮前向传播就可能溢出。4.3 现象车在直道上开得很好一到弯道就冲出去原因训练数据里弯道样本太少网络没见过足够的弯道情况。或者传感器布局不合理看不到弯道内侧的距离。解决收集训练数据的时候特意在弯道多跑几圈增加弯道样本的比例。如果传感器只有正前方一个那肯定不够至少要左前、右前、左侧、右侧各一个。传感器的安装位置也要注意太靠前容易在入弯时反应过度太靠后容易反应不及时。4.4 现象训练好的网络换一台电脑就跑不了原因权重没有持久化或者持久化的时候用了 pickle 但环境不一致。pickle 在不同 Python 版本之间兼容性很差而且有安全风险。解决用 NumPy 的savez和load来存权重跨平台跨版本都没问题。存的时候把每一层的权重和偏置都存进去加载的时候按顺序读出来。# 保存权重 np.savez(weights.npz, *[w for w in network.weights], *[b for b in network.biases]) # 加载权重 data np.load(weights.npz) weights [data[farr_{i}] for i in range(len(network.weights))] biases [data[farr_{ilen(network.weights)}] for i in range(len(network.biases))]4.5 现象训练速度慢得离谱一晚上跑不完原因用了纯 Python 列表做矩阵运算或者 batch_size 设成了 1 但数据量很大。解决换成 NumPy 数组矩阵运算速度差几十倍。如果数据量大适当增大 batch_size用批量梯度下降代替随机梯度下降。另外训练的时候把游戏渲染关掉只跑物理逻辑能省不少时间。5. 二次开发换游戏、换网络结构、加可视化5.1 换一个游戏环境要改哪些地方这个项目的网络部分和游戏部分是解耦的换游戏只需要改game_env.py和sensors.py。核心是保证get_sensors返回一个固定长度的列表step接收转向、油门、刹车三个参数并返回下一状态、奖励和是否结束。如果你换的游戏传感器数量不一样比如从 5 个变成 7 个那网络的输入层也要跟着改。这时候不用重新写网络代码只需要在初始化的时候把layers的第一个元素改成 7 就行。但要注意如果之前训练过权重输入层维度变了旧权重就不能用了得重新训练。我一般会先写一个适配器类把新游戏的接口包装成项目约定的接口这样网络部分一行都不用改。适配器里做三件事读传感器、归一化、调游戏的操控接口。5.2 换网络结构从单隐藏层到多隐藏层单隐藏层对于这个赛车项目基本够用但如果你想试试更深的网络改layers参数就行。比如[5, 16, 12, 3]就是两个隐藏层分别是 16 和 12 个神经元。但要注意层数多了之后反向传播的误差传递会变复杂容易出现梯度消失。纯 Python 实现里没有 BatchNorm 和 Dropout 这些工具层数太多反而不好训练。我的经验是这个项目里隐藏层不要超过两层每层神经元不要超过 20 个。如果你想加激活函数的种类比如隐藏层用 ReLU输出层用 sigmoid那需要在forward和backward里分别处理。ReLU 的导数在正区间是 1负区间是 0实现起来比 sigmoid 还简单。但 ReLU 有个问题负区间的梯度是 0神经元可能「死掉」所以学习率要调小一点。5.3 加一个实时可视化面板训练的时候只看 loss 数字太枯燥加一个实时可视化面板能直观看到车跑得怎么样。最简单的做法是用 matplotlib 的ion模式每跑几帧就更新一次画面。import matplotlib.pyplot as plt plt.ion() fig, ax plt.subplots() line, ax.plot([], []) def update_plot(loss_history): line.set_data(range(len(loss_history)), loss_history) ax.relim() ax.autoscale_view() plt.pause(0.001)这段代码里ion开启交互模式set_data更新曲线数据relim和autoscale_view自动调整坐标轴范围pause让界面刷新。每训练 10 轮调用一次update_plot就能看到 loss 曲线实时下降。如果你想看车在赛道上的实时位置那需要游戏环境提供车辆坐标然后在另一个子图里画出来。这个稍微复杂一些但思路是一样的每帧更新数据重绘。5.4 一个容易忽略的验证技巧改完代码之后怎么确认网络还是对的我一般会做一个「过拟合测试」拿一条训练数据让网络反复学这一条看 loss 能不能降到接近 0。如果连一条数据都学不会那肯定是代码有 bug不用往下跑了。这个测试花不了几秒钟但能省掉大量瞎调参的时间。从那以后我每次改完网络代码都强制走一遍过拟合测试确认前向传播和反向传播没写错再去跑完整训练。希望这个习惯也能帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑