资讯动态

NumPy手写神经网络驾驶赛车:反向传播与行为克隆实战

发布时间:2026/10/24 1:27:07 来源:尧图企业网站定制
简介这份项目资料围绕“用神经网络操纵赛车游戏”展开面向具备Python基础、希望以游戏AI为入口理解神经网络训练与推理流程的学习者它把赛车游戏逻辑、数据采集、模型训练和人机对战串成可运行的小项目帮助读者观察从感知输入到动作输出的闭环而非停留在公式推导。压缩包共54个文件以16个Python源码为主辅以pyc缓存、txt说明、xml工程配置、png车辆与地图素材及wmv对战录屏整体约8.9MB目录涵盖游戏逻辑、TensorFlow训练模块、自实现DNN模块、数据生成与模型测试等部分并附带checkpoint、index等训练产物便于对照阅读两套实现。已有2064人学习或下载说明该案例在神经网络入门与游戏AI实践中具有一定参考价值。读者可借此理解训练数据如何生成、网络参数如何保存与加载、AI如何控制赛车并与真人操作录像对照适合作为神经网络入门后的综合练习与课程设计参考。1. 从像素到方向盘为什么值得自己写一遍神经网络来开赛车让一段 Python 代码盯着屏幕里的赛车游戏画面自己判断该左转还是该收油这件事听起来像调个库就能收工真正的门槛在“自己实现神经网络”这几个字上。你要亲手把一张 96×96 的灰度图压成向量算清每一层的加权和再把误差一层一层传回去中间没有自动微分替你处理。这套方案定位很明确给需要交一份完整课程设计或毕业设计的人一个能照着搭、照着讲、照着改的完整流程。环境是一个 2D 俯视视角的赛车小游戏输入是每帧截下来的画面输出是方向、油门、刹车三个量中间那三层网络只用 numpy 手写。做完之后手里会有一条完整链路截帧、预处理、前向推理、反向更新、按键注入、模型落盘每一环都能单独拿出来解释为什么这么设计。2. 需求拆解与技术选型把“会开车”翻译成 9219 维输入与 3 维输出动手前先把“会开车”这句人话拆成能计算的量。游戏里驾驶员真正做的决策只有三件方向往哪边打、油门给多少、要不要刹。所以网络的输出维度天然就是 3难点全在输入侧——画面里有什么、以什么形式喂进去、喂多少维才算够用。这一章把输入输出的定义、技术栈的取舍和训练路线定下来后面三章才有地基。2.1 状态表示一帧画面怎么压成 9219 个数字原始截图是 800×480 的彩色画面直接展平是 1152000 维参数量会立刻大到单机训练难以承受而且相邻像素高度冗余。常规做法是先裁掉仪表盘和边框只留赛道区域再转灰度、缩放到 96×96。缩放用双线性插值而不是最近邻因为最近邻在缩小 8 倍时会丢掉细窄的赛道边缘车头一旦压线网络看到的像素就会跳变。96×96 展平是 9216 维除以 255 归一化到 [0,1]这一步很关键不归一化的话输入量级是百位数第一层权重的梯度会被放大到发散。光有画面还不够。单帧图像里没有速度信息网络无法区分“静止”和“匀速直行”所以再补三个标量当前速度、车辆相对赛道中心线的横向偏移、车头朝向角各归一化到 [-1,1]。9216 3 9219这就是输入层的维度。如果换成一个没有遥测接口的黑盒游戏这三个量可以退化成从画面底部若干行像素的灰度重心去估计精度差一些但链路能跑通。import numpy as np from PIL import Image # 裁剪区域去掉顶部计时板和底部仪表只保留赛道 ROI (120, 80, 920, 560) # left, top, right, bottom SIZE 96 # 缩放后的边长 FEAT_DIM SIZE * SIZE 3 # 9219 def preprocess(frame_rgb, speed, offset, heading): 把一帧原始画面和三个遥测量拼成网络输入向量 crop frame_rgb[ROI[1]:ROI[3], ROI[0]:ROI[2]] gray crop.mean(axis2).astype(np.uint8) # RGB - 灰度 small Image.fromarray(gray).resize((SIZE, SIZE), Image.BILINEAR) pixels np.asarray(small, dtypenp.float32).reshape(-1) / 255.0 scalars np.array([speed, offset, heading], dtypenp.float32) return np.concatenate([pixels, scalars]) # shape: (9219,)ROI的四个值要按实际窗口位置调裁剪偏了会把护栏外的空白也喂进去网络会学到“看到空白就往右打”这种错误关联。SIZE不建议低于 64再小的话弯道曲率在图上只剩几个像素转向判断会变成随机猜测也不建议高于 128参数和推理耗时都会翻倍收益却很有限。归一化放在拼接之前做避免三个标量被像素的数量级淹没。2.2 为什么不用现成框架手写 numpy 网络的取舍用 PyTorch 写同样的结构大概二十行为什么还要手写答案取决于交付场景。课程设计或毕业设计的答辩现场评委很可能问“反向传播的梯度怎么来的”如果你只能说“框架自动求的”这一问就答不下去。手写一遍之后链式法则在每一层怎么展开、ReLU 的掩码在哪一步乘上去、tanh 的导数为什么是 1−y²都是自己推出来的讲的时候能写到黑板上。代价也很实在没有自动微分梯度要自己验没有 GPU 加速只能跑小网络没有现成的优化器Adam 得自己实现一份。但这三条在这个项目里都不致命——网络总参数量不到 250 万CPU 上前向一次两毫秒左右训练集撑死几万条样本SGD 加动量或者简版 Adam 完全够用。真正需要框架的场景是输入直接上 224×224 彩色图接卷积网络那是另一个量级的问题。我一般的取舍是网络层数不超过四层、参数量在千万以下、又需要讲清楚原理就用 numpy 手写超过这个规模再上框架否则时间全花在调数值稳定性上。2.3 训练路线行为克隆打底进化策略微调训练范式有三条路。监督学习最直接人先手动开二十圈把每一帧和当时的按键记录下来做成输入标签对用均方误差去拟合。这条路收敛快、两天能出结果但有个典型缺陷——分布偏移。人开的时候车一直在赛道中间网络学到的都是“正常状态”下的应对一旦推理时车偏出去一点它看到的画面在训练集里从未出现过输出就会失控然后越偏越远最终撞墙。这在行为克隆里叫 covariate shift是必须提前想好对策的。常见补法有三种。数据增强最省事把画面左右翻转、标签的转向取反等于把训练集翻倍同时让网络见到更多边缘位置。关键区域加密采样是第二种在弯道前 30 帧内提高采样权重直道上的样本大量丢弃因为直道本来就没什么决策含量。第三种是 DAgger 式的迭代纠正——先训一个初版让它自己开人盯着一旦出现偏离就接管并记录这一帧的正确操作把这些“犯错现场”的样本补进训练集重训。三轮下来模型在边缘状态的鲁棒性会明显好于纯克隆。如果还想再往上抬一点圈速可以接一段进化策略做微调把网络权重当基因跑一整圈的总耗时当适应度用 (1λ) 的简单策略在权重上加高斯噪声保留跑得更快的那个。这一步不依赖标签能突破人类示范的上限但需要游戏能稳定重开、圈速可读工程量不小。我的建议是把它作为加分项先把行为克隆加 DAgger 跑通再考虑。3. 总体架构设计五层数据流与三个进程的职责边界网络只是一块真正决定这套东西能不能稳定跑起来的是模块划分和时序。截图、推理、按键这三件事对延迟极其敏感任何一环卡住游戏里的车就会画出诡异的轨迹训练则是重活一次反向传播可能几十毫秒。把这两类负载放在同一个循环里结果必然是推理被训练拖慢帧率忽高忽低。所以架构的第一原则是在线推理和离线训练彻底分开。3.1 五层职责划分从屏幕像素到键盘事件整条链路拆成五层每层只做一件事层与层之间用明确的数据结构交接。层级职责输入输出运行频率采集层抓取游戏窗口画面与遥测屏幕区域坐标800×480 RGB 帧 3 个标量30 Hz预处理层裁剪、灰度、缩放、归一化、拼接原始帧9219 维 float32 向量30 Hz推理层前向传播特征向量3 维控制量 [-1,1]30 Hz控制层死区过滤、限幅、按键按下/释放控制量键盘事件30 Hz训练层采样、反向传播、权重更新、落盘样本对权重文件离线 1 Hz这样划分的好处是每一层都能单独测。预处理层可以脱离游戏跑喂一批录好的截图看输出的向量范围对不对推理层可以脱离预处理跑喂随机向量看输出是否在 [-1,1] 内控制层可以脱离网络跑手动给固定值看按键有没有正确按下和释放。定位问题时能迅速缩小范围不至于在几万行日志里翻找。3.2 进程模型为什么推理和训练必须分家在线部分采集 预处理 推理 控制放在主进程里串行执行训练放在单独进程通过一个共享队列或者直接读写 npz 文件交换样本。不用多线程的原因是 Python 的 GIL——即便训练在另一个线程numpy 的大矩阵运算在释放 GIL 之前仍会抢占调度推理的延迟会出现尖刺。用独立进程各自拿一个核互不干扰。主进程的循环必须加节流。游戏本身可能跑在 60 FPS但截图和推理只需要 30 Hz用time.sleep把多余的时间让出去否则 CPU 空转会把风扇拉满笔记本上还会触发降频反而更慢。节流的写法是按目标周期补差而不是固定 sleep因为预处理耗时是浮动的。import time TARGET_DT 1.0 / 30.0 # 目标 30 Hz def main_loop(net, game): last time.perf_counter() while not game.over(): t0 time.perf_counter() frame, speed, offset, heading game.grab() x preprocess(frame, speed, offset, heading) action net.forward(x[None, :])[0] # 前向推理 game.apply(action) # 按键注入 # 按目标周期补差而不是固定 sleep(TARGET_DT) elapsed time.perf_counter() - t0 time.sleep(max(0.0, TARGET_DT - elapsed))这里TARGET_DT定成 1/30 是经验值低于 20 Hz转向指令会明显滞后于画面车在弯里会冲出去高于 60 Hz游戏渲染本身就撑不住截图拿到的是重复帧白白浪费算力。elapsed要减去本轮实际耗时再补差保证循环稳定在 30 Hz 附近而不是“30 Hz 减掉处理时间”这种越跑越慢的写法。3.3 时间预算11 毫秒怎么分给五个环节一帧 33 毫秒的预算实际分配到各环节大概是这个量级截图 3 到 5 毫秒取决于分辨率和是否复用句柄、预处理 4 到 6 毫秒缩放是主要开销、前向传播 2 到 3 毫秒三层全连接CPU 单线程、决策与按键 1 到 2 毫秒。加起来 11 到 16 毫秒留出一半余量给系统抖动。如果实测下来截图占了大头先查是不是每次都新建了 mss 句柄——这是最容易踩的坑新建句柄的开销比截图本身还大。如果前向传播超了 5 毫秒先看输入维度是不是写错了比如把 96 写成了 192再看权重是不是 float64——numpy 默认创建 float64参数量一大会直接翻倍初始化时就要强制dtypenp.float32。4. 核心模块实现手写全连接网络与驾驶决策链路这一章是整合核心网络、反向传播、梯度检查、游戏对接、训练循环五块代码全部给出来每块后面说明设计取舍和参数含义。代码是完整的可以复制到一个文件里跑通只需要按自己的窗口位置改 ROI。4.1 参数初始化与 He 初始化import numpy as np class DriveNet: def __init__(self, in_dim9219, hidden(256, 128), out_dim3, seed42): rng np.random.default_rng(seed) dims [in_dim, *hidden, out_dim] self.W, self.b [], [] for i in range(len(dims) - 1): # He 初始化方差 2/fan_in适配 ReLU避免深层激活值逐层衰减 scale np.sqrt(2.0 / dims[i]) self.W.append(rng.normal(0, scale, (dims[i], dims[i1])).astype(np.float32)) self.b.append(np.zeros(dims[i1], dtypenp.float32))第一层 9219×256约 236 万参数是全部参数量的九成以上第二层 256×128第三层 128×3。隐藏层宽度选 256 和 128 是按经验来的再窄比如 64会欠拟合车在弯道里识别不出曲率趋势再宽比如 512训练时间翻倍收益不到 5%。偏置初始化为 0 是安全的因为前面用了 He 初始化不会出现所有神经元输出相同导致的对称性问题。权重用float32是刻意的。numpy 默认float64参数翻倍不说矩阵乘法还会走非 SIMD 的慢路径实测前向耗时差将近一倍。4.2 前向传播9219 维进3 维出def forward(self, x, cacheFalse): x: (batch, 9219)。返回 (batch, 3)控制量在 [-1, 1]。 a x.astype(np.float32, copyFalse) acts [a] for i in range(len(self.W) - 1): z a self.W[i] self.b[i] a np.maximum(z, 0) # ReLU负半轴直接截断 acts.append(a) z a self.W[-1] self.b[-1] out np.tanh(z) # 输出压到 [-1, 1] acts.append(out) return (out, acts) if cache else out输出层用 tanh 而不是 sigmoid因为转向需要正负两个方向sigmoid 只能给 [0,1]还得再做一次减 0.5 的平移不如直接用 tanh。油门和刹车其实只需要 [0,1]但为了三个输出共用一个输出层、共用一套损失统一用 tanh控制层再做映射负值视为刹车正值视为油门。cacheTrue时把每层的中间结果存下来反向传播要用。acts[0]是输入acts[-1]是输出中间是各隐藏层的激活值这个顺序在反向里必须严格对应错一位就会得到看起来“能收敛但效果很差”的模型排查起来极其费时。4.3 反向传播链式法则逐层展开def backward(self, acts, grad_out): acts 来自 forward(cacheTrue)grad_out 是损失对输出的梯度 gw [None] * len(self.W) gb [None] * len(self.b) delta grad_out * (1.0 - acts[-1] ** 2) # tanh 导数1 - y^2 for i in range(len(self.W) - 1, -1, -1): gw[i] acts[i].T delta # (fan_in, fan_out) gb[i] delta.sum(axis0) # (fan_out,) if i 0: delta (delta self.W[i].T) * (acts[i] 0) # 穿过 ReLU 掩码 return gw, gbgrad_out由损失函数给出。用均方误差时损失 L mean((pred − target)²)对 pred 的梯度是 2(pred − target)/N。acts[i] 0这个布尔数组就是 ReLU 的导数激活值大于 0 的神经元梯度原样通过小于等于 0 的直接归零。这一步如果写成z 0效果等价因为 relu(z) 0 当且仅当 z 0但用激活值更省一次比较。最后一步更新def step(self, gw, gb, lr): for i in range(len(self.W)): self.W[i] - lr * gw[i] self.b[i] - lr * gb[i].astype(np.float32)学习率lr从 1e-3 起调配合小批量batch 32 到 64。如果损失前 200 步下降很慢先加到 3e-3 试试如果出现损失突然变成 nan八成是学习率太大导致激活值爆炸退回 1e-4 重新跑。4.4 梯度检查手写网络必须保留的检查步骤没有自动微分就不能默认梯度写对了。写一个数值梯度对照脚本用中心差分去逼近解析梯度相对误差控制在 1e-6 以内再往下走。def grad_check(net, x, y, eps1e-4): out, acts net.forward(x, cacheTrue) diff out - y loss float((diff ** 2).mean()) grad_out 2.0 * diff / diff.size gw, gb net.backward(acts, grad_out) # 抽第一层某个权重做数值验证 i, j 10, 5 old net.W[0][i, j] net.W[0][i, j] old eps loss_p float(((net.forward(x) - y) ** 2).mean()) net.W[0][i, j] old - eps loss_m float(((net.forward(x) - y) ** 2).mean()) net.W[0][i, j] old num (loss_p - loss_m) / (2 * eps) ana gw[0][i, j] rel abs(num - ana) / max(1e-8, abs(num) abs(ana)) print(f数值梯度{num:.6e} 解析梯度{ana:.6e} 相对误差{rel:.2e})eps取 1e-4 是 float32 下的折中太小会被舍入误差淹没太大会引入截断误差。这段代码只抽查一个权重够用要更保险就把每层随机抽十个位置循环一遍。相对误差在 1e-4 以内都算正常超过 1e-2 说明反向传播里有一步写错了最常见的是漏乘 ReLU 掩码或者矩阵转置的方向搞反。4.5 与游戏对接窗口捕获和按键注入import mss from pynput.keyboard import Controller, Key _sct mss.mss() # 复用句柄避免每次新建 _kb Controller() def grab(region): shot np.array(_sct.grab(region))[:, :, :3] # BGRA - BGR return shot[:, :, ::-1] # BGR - RGB def apply_action(out, dead_zone0.15): steer, throttle, brake float(out[0]), float(out[1]), float(out[2]) if steer -dead_zone: _kb.press(Key.left); _kb.release(Key.right) elif steer dead_zone: _kb.press(Key.right); _kb.release(Key.left) else: _kb.release(Key.left); _kb.release(Key.right) (_kb.press if throttle 0.3 else _kb.release)(Key.up) (_kb.press if brake 0.3 else _kb.release)(Key.down)mss.mss()在模块级创建一次并复用这是性能上的硬要求第 5 章会专门讲不复用的后果。dead_zone是转向死区0.15 意味着网络输出在 ±0.15 以内就当作“方向盘回正”防止直道上左右键高频交替导致车身抖动——这个值调大会让车反应迟钝调小会让车画龙通常 0.1 到 0.2 之间试。按键注入用pynput而不是pyautogui后者每次调用都有约 0.1 秒的内部延迟30 Hz 的循环根本撑不住。按下和释放要配对尤其在退出循环时必须在finally里把所有键释放一遍否则游戏里的车会一直踩着油门冲向护栏。4.6 训练循环与模型落盘def train(net, X, Y, epochs30, batch64, lr1e-3): n X.shape[0] for ep in range(epochs): idx np.random.permutation(n) total 0.0 for s in range(0, n, batch): b idx[s:sbatch] xb, yb X[b], Y[b] out, acts net.forward(xb, cacheTrue) diff out - yb total float((diff ** 2).sum()) grad_out 2.0 * diff / diff.size gw, gb net.backward(acts, grad_out) net.step(gw, gb, lr) print(fepoch {ep:02d} mse{total/n:.5f}) np.savez(drive_net.npz, **{fW{i}: w for i, w in enumerate(net.W)}, **{fb{i}: b for i, b in enumerate(net.b)})X的形状是 (N, 9219)Y是 (N, 3)N一般两三万条就够——二十圈、每圈 1500 帧左右去掉直道冗余后留下的量。epochs30是经验值再多会开始过拟合训练集表现是训练集 mse 一路降到 0.005 以下但实车跑起来反而更差。判断过拟合的土办法是留出两圈做验证集训练集和验证集的 mse 差距超过三倍就停。落盘用np.savez而不是 pickle原因是纯数组、无依赖、跨版本可读答辩换台机器也能加载。5. 避坑清单训练不收敛时最该先查的五件事下面五条都是我在类似项目里真实翻过车的现场按出现频率排序。每条按“看到什么现象、根子在哪、怎么改”写遇到问题可以当排查清单从上往下过。5.1 损失卡在 0.02 不动输出几乎恒定现象是前几十步损失下降很快然后长时间停在 0.02 附近验证一下输出三个控制量基本是常数车只会直着往前开。原因通常是学习率偏大把输出层推进了 tanh 的饱和区梯度 1−y² 趋近于 0反向传播传不回去网络就“躺平”了。改法是先把学习率降到 3e-4 重跑同时把输入归一化再检查一遍——如果输入量级是几百等效于学习率被放大了几百倍同样会饱和。还有一种可能是标签本身自相矛盾比如同一帧画面在两个弯道里被标了相反的转向网络只能学平均值这种情况要靠加入朝向角这类能区分场景的标量来解决。5.2 直道上左右画龙弯道反而正常现象很反直觉弯道里车走得挺稳一到直道就开始左右扭。根子是单帧输入缺少横向速度信息。网络看到的是“车在画面中间”下一帧车稍微偏左画面还是“差不多在中间”它只能靠猜猜错就往反方向修正形成振荡。改法是补特征把横向偏移对时间求一次差分当作横向速度喂进去或者干脆堆三帧历史画面输入维度变成 27648参数量增加但可接受。另一个便宜的办法是加转向死区把 ±0.15 内的输出强制归零能压掉大部分抖动但治标不治本。5.3 训练集 mse 很低实车三秒撞墙这是行为克隆的典型问题训练集里全是人正常驾驶的“好状态”模型没见过车头已经偏出赛道的样子。一旦推理时出现微小偏差它看到的是分布外样本输出开始乱偏差被放大最后撞墙。解决办法是 DAgger让模型自己开人盯着出现偏离就立刻接管并记录这一帧的正确操作把这些“犯错现场”补进训练集重训两到三轮。同时把左右翻转增强打开相当于把赛道镜像了一遍模型对左右边缘的覆盖会好很多。判断是否已经缓解看验证集里“车偏离中心线超过阈值”的样本占比低于 15% 才算稳。5.4 同一帧画面两次推理结果不一样车在抖排除网络本身前向是确定性的问题一定在预处理。最常见的两个来源一是缩放用了带随机性的库或者图像来源本身在变——比如截图区域被其他窗口遮挡了一角画面内容每帧都在变二是归一化用了“当前帧的均值方差”这种动态统计帧与帧之间统计量不同同一个场景会得到不同输入。改法是归一化固定成除以 255截图前把游戏窗口置顶并用固定 ROI。还有一种隐蔽情况是截图拿到了撕裂的帧上半帧是旧的下半帧是新的把截图频率从 30 Hz 降到 25 Hz 或者开启游戏的双缓冲能缓解。5.5 跑十几分钟越来越卡最后卡死现象是前五分钟流畅后面帧率一路掉最终画面定格。三个高发原因第一mss.mss()每次截图都新建对象句柄泄漏内存和句柄数一起涨改成模块级复用第二采集样本的列表无限追加训练线程来不及消费内存被撑爆要加一个定长队列满了就丢最旧的第三按键只按下没释放系统里堆积了成百上千个未释放的键状态游戏输入队列堵死。第三条最容易被忽略因为代码里看不出错只是少写了一行 release。稳妥的写法是把所有按键释放逻辑放进try/finally不管循环怎么退出都执行一遍。6. 验收与答辩用三张表和一段回放证明网络真的在开车这套东西做完最容易出问题的地方就是验收你没法像普通功能那样说“点一下按钮弹窗出来了”就完事得用可量化的指标证明网络确实在驾驶。我一般准备三张表。第一张是基础指标表跑十圈取平均数据直接从日志里统计。指标合格线测量方式单圈完成率≥ 8/10十次重开统计完整跑完的圈数平均圈速与人工示范差距 ≤ 15%同一赛道人工跑十圈取均值做基准每圈碰撞次数≤ 1.5游戏内置碰撞计数或画面检测单帧推理延迟≤ 5 ms循环内 time.perf_counter 统计模型参数量≤ 300 万各层权重 shape 累乘求和第二张是消融对比表用来回答“为什么要这么设计”。把输入尺寸 64/96/128、隐藏层宽度 128/256/512、有没有加三个遥测量、有没有做翻转增强各跑五圈记录圈速和完成率。答辩时这张表最能说服人因为它证明每个设计选择都有数据支撑不是拍脑袋。第三张是失败案例表列出模型在哪些场景下会挂急弯、连续 S 弯、赛道边缘、被遮挡的弯心。每个场景写清楚失败表现和已尝试的改进。这张表比前两张更能体现工程思维——承认边界比吹嘘效果可信得多。现场演示按三步走。第一步正常跑三圈展示完整链路第二步打开一个开关把推理结果实时打印在终端上让评委看到转向值随弯道变化的曲线第三步跑grad_check现场输出数值梯度和解析梯度的相对误差。第三步是关键很多人讲不清楚反向传播一跑这个脚本误差在 1e-6 量级比任何解释都有力。提问环节高频的几问提前准备为什么不用卷积网络——因为输入已经缩到 96×96 且赛道结构简单全连接够用上卷积会让参数量和训练成本翻几倍收益不明显为什么不用强化学习——强化学习需要大量试错单机跑几百万帧不现实行为克隆加 DAgger 是性价比更高的路线怎么证明不是过拟合——拿验证集 mse 和实车表现对照两者趋势一致就不算过拟合。从那以后我每次交这类方案前都会强制走一遍三件套梯度检查脚本跑通、十分钟无人值守回放不掉帧、把模型文件删掉从原始数据重训一次确认能复现。这三步做完心里才有底。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑