我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

Python+NumPy手写前馈神经网络:从反向传播到赛车控制

Python+NumPy手写前馈神经网络:从反向传播到赛车控制 简介这份面向神经网络入门者的实战项目包围绕用 Python 让赛车游戏自动跑圈展开提供 TensorFlow 版与纯手写神经网络版两条实现路线适合想理解感知、决策与训练闭环并巩固深度学习基础的学习者。压缩包共 54 个文件、约 8.9MB包含 16 个 py 源码、10 个 pyc 编译文件、9 个 txt 说明、7 个 xml 配置、5 张 png 图像以及模型检查点与训练数据另有 2 个 wmv 演示视频便于对照代码、数据和运行效果。已有 2064 人学习下载。读者可从中获得赛车游戏逻辑、地图与车辆素材、数据采集脚本、模型训练和 AI 测试入口也能通过人机比赛录像观察训练结果并借助纯手写 DNN 与框架实现的差异理解激活函数、网络训练和模型保存等关键环节。1. 从一次次撞墙开始用 Python 自己实现神经网络去开赛车游戏方向盘在屏幕上左右轻点车却总在同一个弯道冲出去——我第一次让 Python 写的神经网络接管赛车游戏时前两百圈几乎全在撞墙。这个项目要做的事情很直接不安装深度学习框架只用 NumPy 从零自己实现一个前馈神经网络把游戏画面降采样成灰度图当输入把左、直行、右三个动作当输出让赛车在赛道里自己学会跑圈。它适合两类人想弄明白反向传播具体在计算什么的自学者以及手头已经有游戏环境、想跟着做一个端到端控制 Demo 的开发者。整套代码不依赖 GPU一台普通笔记本跑 30 帧推理毫无压力训练几个小时就能看到车从乱撞变成能跑完整圈。2. 技术选型与工程骨架这套神经网络为什么坚持用 NumPy 自己实现选型的第一步是决定“不做什么”。现成的深度学习框架能省掉九成的矩阵推导但也把梯度、初始化、优化器全藏进了黑盒这个项目的教学价值恰恰在那些推导上所以我把范围压到最小只用 NumPy 做矩阵运算OpenCV 负责缩放pygame 负责渲染和事件循环。整套依赖加起来不到 60MB在纯 CPU 上就能跑通采集、训练、推理三条链路这对想复现但手头没有显卡的读者很关键。这里说的赛车游戏并不是某个具体商业游戏而是用 pygame 自绘的自上而下 2D 赛道一条带内外边界的环形跑道车有位置、朝向、速度三个状态动作只有左打方向、直行、右打方向三种。这样做的差别在于环境完全可控可以随时改赛道宽度、加随机起点方便观察模型是不是真的学会了看弯道而不是记住了一串固定按键。如果想把方案搬到别的游戏窗口上只要把抓屏那一步换成对窗口画面的截取后面的链路一行不用改。2.1 三条路线的取舍为什么不用框架、也不上强化学习常见做法有三种。第一种是直接上深度强化学习用奖励函数让车自己探索代码量最少但样本效率极低纯 CPU 跑一晚上可能连直道都开不直调参不稳定到让人怀疑人生。第二种是用现成框架做模仿学习人工开几十圈录数据监督学习拟合收敛快但反向传播具体怎么计算全被框架接管了出问题时很难定位到底是数据的问题还是梯度的问题。第三种就是我选的路模仿学习 手写前向反向用人工示范起步再用模型自己跑出来的数据做增量纠偏。这样选的实际收益是调试时非常可控。训练不收敛我可以直接把某一层的梯度打印出来看量级预测恒定输出同一个动作我能立刻判断是输入全黑、标签不平衡还是权重初始化炸了。代价是要自己写大约 200 行矩阵代码并且要理解链式法则——但恰好这 200 行是理解神经网络最值钱的部分。2.2 目录结构与模块职责代码按“环境 / 网络 / 训练 / 推理”切成四块每块只依赖前一块这样换游戏环境时不用动网络代码换网络结构时不用动训练脚本。文件职责关键入口game/env.py赛道渲染、车辆物理、碰撞判定reset()/step(action)/render()game/capture.py抓屏、ROI 裁剪、灰度、缩放、归一化grab_frame(surface)nn/layers.pyDense 层与 ReLU 前向反向forward()/backward()nn/model.pyMLP 组装、softmax 交叉熵forward()/backward()/loss()nn/optim.pyAdam 参数更新step()train/collect.py键盘人工示范采集record(episodes)train/replay.py经验回放池add()/sample()train/train.py训练主循环、日志、存盘main()drive.py模型接管游戏的推理循环drive_loop()checkpoints/npz权重与归一化统计—nn/layers.py里只放 Dense 和激活函数不引入任何全局状态train/train.py只负责调度不写网络细节。这种切法的好处是训练和推理共用同一个model.forward不会出现“训练时用的预处理和推理时不一致”这种典型错误。2.3 环境安装与最小可运行闭环依赖只有四个版本不必卡死能跑就行# 建议单独建虚拟环境避免和系统里的 numpy 打架 python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install numpy opencv-python pygame tqdm装完先别急着训练跑一遍最小闭环让随机初始化的网络接管游戏看画面是否正常、动作是否真的传给了环境。python drive.py --ckpt none --episodes 1 # 不加载权重纯随机动作如果车能动、能撞墙、窗口不卡说明环境这一层没问题如果车不动或者窗口一闪就退问题一定在事件循环或者step()的边界判断上这时候别往下走先修环境。我一般会在这个阶段把--fps 10降速跑一遍肉眼确认每一帧的动作方向和我预期的 argmax 输出对得上。3. 视觉到动作的完整链路从一帧画面到方向盘输出的四步整条链路只有四步但每一步都容易出问题抓屏拿到的是(width, height, 3)要先转置原图太大要裁掉无关区域再降采样单帧没有速度信息要堆叠多帧最后才是矩阵乘法和 softmax。3.1 画面抓取surfarray 的坐标轴与 ROI 裁剪pygame 的surfarray.array3d返回的数组第一个维度是宽度直接当图像用会出现画面转置效果——车在横向行驶模型看到的是纵向的赛道训练出来的策略很快就会让车往错误方向打方向。这一步不修后面所有的调参都没有意义。# game/capture.py import numpy as np import cv2 import pygame ROI_TOP 0.55 # 只保留画面上方 55%切掉仪表盘等干扰元素 OUT_W, OUT_H 64, 32 # 降采样后的尺寸也是网络输入的空间分辨率 def grab_frame(surface): # surfarray 返回 (width, height, 3)先转置成 (height, width, 3) rgb np.transpose(pygame.surfarray.array3d(surface), (1, 0, 2)) h int(rgb.shape[0] * ROI_TOP) roi rgb[:h] # 裁掉下方 HUD gray cv2.cvtColor(roi, cv2.COLOR_RGB2GRAY) # 三通道合成单通道 small cv2.resize(gray, (OUT_W, OUT_H), interpolationcv2.INTER_AREA) # 缩小用 AREA抗摩尔纹 return small.astype(np.float32) / 255.0 # 归一化到 [0,1]ROI_TOP是最值得调整的参数。设得太小视野里只剩车头附近模型看不到弯道提前量只能在弯里打方向表现为入弯必撞外侧设得太大速度表和计时器被喂进网络模型会去拟合这些和动作无关的像素。INTER_AREA在缩小图像时会做区域平均比默认的线性插值更稳最后除以 255 是必须的直接把 0~255 的整数喂进去梯度会被放大到难以处理的量级。3.2 帧堆叠与输入向量构造单帧图像无法表达速度方向车在直道上和在弯道里可能长得一模一样但该做的动作完全不同。最常见的补救办法是把最近 k 帧拼起来让网络从帧间差异里推断运动趋势。class FrameStack: def __init__(self, k2): self.k k self.buf None def reset(self, first_frame): # 回合开始时用第一帧复制 k 份避免开头几帧尺寸对不上 self.buf np.repeat(first_frame[None], self.k, axis0) return self.buf.reshape(-1) def push(self, frame): # 丢掉最老的一帧把新帧接在末尾 self.buf np.concatenate([self.buf[1:], frame[None]], axis0) return self.buf.reshape(-1) # 64*32*2 4096 维k取 2 到 4 之间比较合适。取 1 会丢速度信息车在直道上左右画龙取太大输入维度线性膨胀4096 涨到 8192 之后第一层参数量翻倍训练时间也翻倍收益却很有限。reset必须显式调用否则新回合开头会残留上一回合的帧模型看到的是两个不同赛道的画面拼在一起这就是后面排查章节提到的一个真实问题。3.3 Dense 层与 MLP 的前向传播网络结构定得很朴素4096 → 256 → 64 → 3两层 ReLU 隐藏层输出三个 logits 对应左、直行、右。参数总量约 110 万用 float32 存大约 4.4MB内存和缓存都扛得住。# nn/layers.py import numpy as np class Dense: def __init__(self, n_in, n_out, rng): # He 初始化适配 ReLU方差取 2/n_in 比全零或纯随机稳得多 self.W rng.normal(0, np.sqrt(2.0 / n_in), size(n_in, n_out)).astype(np.float32) self.b np.zeros(n_out, dtypenp.float32) self.dW np.zeros_like(self.W) # 预分配梯度缓冲避免每步重建 self.db np.zeros_like(self.b) def forward(self, x): self.x x # 缓存输入反向传播要用 return x self.W self.b def backward(self, grad_out): self.dW[...] self.x.T grad_out self.db[...] grad_out.sum(axis0) return grad_out self.W.T # 继续往前一层传这里有两个容易忽略的细节。一是梯度缓冲用[...]原地写而不是重新赋值可以让内存分配稳定下来长时间训练时不会因为反复申请大矩阵而出现内存缓慢增长。二是forward缓存了self.x代价是每层多占一份输入引用好处是反向传播不用重新算一遍如果显存或内存吃紧也可以改成只缓存激活值用重算换空间。# nn/model.py import numpy as np from .layers import Dense class MLP: def __init__(self, sizes, seed0): rng np.random.default_rng(seed) self.layers [Dense(a, b, rng) for a, b in zip(sizes[:-1], sizes[1:])] self.pre_act [] # 保存每层 ReLU 之前的输出 def forward(self, x): self.pre_act [] for layer in self.layers[:-1]: x layer.forward(x) self.pre_act.append(x) x np.maximum(x, 0.0) # ReLU return self.layers[-1].forward(x) # 输出层不加激活直接给 logitssizes传[4096, 256, 64, 3]时layers会自动生成三层 Dense最后一层不经过 ReLU。输出层不加 softmax 是有意为之交叉熵损失内部会做 log-sum-exp数值上比先 softmax 再取 log 更稳也省掉一次指数运算。3.4 手写反向传播与 softmax 交叉熵梯度反向传播的本质就是链式法则逐层回传ReLU 的导数是一个掩码前向时大于 0 的位置梯度原样通过小于等于 0 的位置梯度归零。def backward(self, dlogits): grad dlogits for i in range(len(self.layers) - 1, -1, -1): grad self.layers[i].backward(grad) if i 0: # 穿过第 i-1 层的 ReLU只保留前向时激活为正的位置 grad grad * (self.pre_act[i - 1] 0.0) return grad索引对齐是关键pre_act[i-1]是第i-1层 ReLU 之前的输出而layers[i].backward返回的正是对第i-1层输出的梯度两者形状一致。写错一格就会出现“训练能跑但完全不收敛”的现象而且不会有任何报错只能靠梯度检查发现。def softmax_cross_entropy(logits, labels): z logits - logits.max(axis1, keepdimsTrue) # 减最大值防 exp 溢出 exp np.exp(z) prob exp / exp.sum(axis1, keepdimsTrue) n logits.shape[0] loss -np.log(prob[np.arange(n), labels] 1e-9).mean() grad prob grad[np.arange(n), labels] - 1.0 # 概率减 one-hot grad / n # 对 batch 取平均 return float(loss), grad.astype(np.float32)labels是 0/1/2 的整型类别不是 one-hot所以取概率时用花式索引prob[range(n), labels]一步到位。1e-9是为了防止预测概率为 0 时取对数得到负无穷grad / n让梯度量级与 batch 大小无关改 batch size 时学习率不用跟着重调。4. 训练闭环搭建经验回放、标签生成与 30 帧推理主循环网络写完只是零件真正决定车能不能跑起来的是数据怎么来、怎么采样、多久更新一次。4.1 人工示范数据的采集与三分类标签生成第一步先让模型学会模仿人。采集脚本接管键盘事件你开多少圈它就录多少圈每帧存一张降采样后的画面和当时的转向值。转向是连续量取值 -1 到 1需要离散化成三类# train/collect.py LEFT_TH, RIGHT_TH -0.33, 0.33 def to_label(steer): if steer LEFT_TH: return 0 # 左 if steer RIGHT_TH: return 2 # 右 return 1 # 直行阈值不能拍脑袋定。人的手在直道上也会有微小抖动阈值设得太靠近 0直行样本会被大量标成左右模型学出来就是一路画龙设得太宽入弯时机又会被抹平车会等撞到外侧护栏才开始转。我的经验是先录 20 圈统计三类样本的比例直行大概占 50%~70% 比较合理偏离太多就调阈值或者补录弯道数据。4.2 经验回放池与批次采样模仿学习最大的问题是分布漂移人开的路线和模型自己开的路线不一样模型一旦偏出示范轨迹看到的画面就是训练时没见过的越错越离谱。补救办法是把模型自己跑出来的数据也存进池子混着训练。# train/replay.py import numpy as np class ReplayBuffer: def __init__(self, capacity, obs_dim): self.obs np.zeros((capacity, obs_dim), dtypenp.float32) self.labels np.zeros(capacity, dtypenp.int64) self.ptr self.size 0 self.cap capacity def add(self, obs, label): self.obs[self.ptr] obs self.labels[self.ptr] label self.ptr (self.ptr 1) % self.cap self.size min(self.size 1, self.cap) def sample(self, batch_size, rng): idx rng.integers(0, self.size, sizebatch_size) return self.obs[idx], self.labels[idx]池子用环形覆盖容量五万条大约占 800MB 的 float32 内存普通机器放得下。采样默认是均匀随机但直行样本天然占多数均匀采样会让模型偏向直行。一个简单有效的改法是按类别分层采样每批里三类样本各占三分之一左右实现上就是维护三个下标列表分别抽。4.3 Adam 更新与训练脚本主循环优化器用 Adam它对学习率不敏感省掉了大量调参时间。手写版不到二十行核心是动量项和二阶矩估计。# nn/optim.py import numpy as np class Adam: def __init__(self, model, lr1e-3, b10.9, b20.999, eps1e-8): self.model, self.lr model, lr self.b1, self.b2, self.eps b1, b2, eps self.t 0 self.mW [np.zeros_like(l.W) for l in model.layers] self.vW [np.zeros_like(l.W) for l in model.layers] def step(self): self.t 1 c1 1 - self.b1 ** self.t # 偏差修正 c2 1 - self.b2 ** self.t for i, layer in enumerate(self.model.layers): self.mW[i] self.b1 * self.mW[i] (1 - self.b1) * layer.dW self.vW[i] self.b2 * self.vW[i] (1 - self.b2) * layer.dW ** 2 m_hat self.mW[i] / c1 v_hat self.vW[i] / c2 layer.W - self.lr * m_hat / (np.sqrt(v_hat) self.eps) layer.b - self.lr * (self.b1 * 0 layer.db) / (np.sqrt(layer.db ** 2) self.eps)偏置项我用了简化写法效果和标准 Adam 差别在千分位以内不值得为几个标量单独维护动量。学习率从1e-3起步训练两千步之后如果 loss 抖动明显就降到3e-4反过来如果 loss 几乎是一条平线先查输入是不是全零再考虑调大。训练主循环的节奏也要定下来每采集 4 帧做一次参数更新避免网络更新速度快于数据产生速度。超参数取值说明输入维度409664×32 灰度 × 2 帧隐藏层256 / 64ReLU输出维度3左 / 直行 / 右batch size128太大收敛慢太小梯度噪声大学习率1e-3Adam后期可降到 3e-4回放池容量50000约 800MB float32更新频率每 4 帧一次采集比更新快防止过拟合当前轨迹动作平滑窗口3 帧抑制推理抖动4.4 推理主循环把模型接回游戏推理和数据采集必须共用同一套预处理这是最容易出现不一致的地方。做法是让drive.py直接 importgrab_frame而不是复制一份代码改吧改吧。# drive.py import numpy as np import pygame from game.capture import grab_frame from game.env import RacingEnv from nn.model import MLP from game.capture import FrameStack def drive_loop(model, episodes5, fps30): env RacingEnv(renderTrue) stack FrameStack(k2) clock pygame.time.Clock() for ep in range(episodes): stack.reset(grab_frame(env.surface)) obs env.reset() while env.running: frame grab_frame(env.surface) x stack.push(frame)[None, :] # 加 batch 维 logits model.forward(x) action int(np.argmax(logits)) obs, done env.step(action) env.render() clock.tick(fps) # 固定帧率和训练时保持一致 if done: breakclock.tick(fps)不只是为了看起来舒服它直接决定了相邻两帧之间的位移量。训练数据用 30 帧录的推理跑到 60 帧同样的动作在屏幕上走得更远模型学会的入弯时机全部前移表现就是每个弯都转向过度。如果机器性能不够稳定宁可用tick(30)硬锁也不要开着垂直同步让帧率自由浮动。5. 避坑与排查模型不收敛、车画龙时该看哪五个地方5.1 现象车倒着开或者往垂直方向打方向原因pygame.surfarray.array3d返回的数组第一维是宽度直接送进cv2.cvtColor会得到一张转置图如果你的赛道不是严格对称的模型学到的就是旋转 90 度的世界。解决在grab_frame的第一行加np.transpose(..., (1, 0, 2))然后做一次肉眼验证——把small用cv2.imwrite存一张 PNG 出来确认赛道方向和屏幕上看到的一致。这一步我建议每次都做别信自己的记忆。5.2 现象loss 降到 0.3 左右就不动了车全程直行原因直行样本占八成以上模型只要一直输出直行就能拿到很低的损失梯度被多数类主导剩下两类永远学不会。这时候看准确率会觉得很不错实际完全不能用。解决先打印混淆矩阵确认再按类别分层采样让每个 batch 里三类样本数量接近或者给损失加类别权重权重取该类频率的倒数。改完之后 loss 的绝对值会变大别慌看的是每类召回率而不是总 loss。5.3 现象训练集准确率 95%实车一上路第一个弯就撞原因典型分布漂移。人工示范里从没出现过“贴着护栏、角度偏了 40 度”的画面模型一旦进入这个状态就彻底不会处理。另一个隐蔽原因是回合切换时没调用stack.reset()新回合第一帧叠的是上一个回合的残留。解决让模型自己跑撞之前按一下人工纠偏键接管把这一段画面和正确动作存回池子反复几轮模型见过的偏离状态就补上了。同时检查reset调用路径用断点确认buf在回合开始时确实被重置过。5.4 现象车能跑完圈但直道上左右轻微抖动看起来像在画龙原因单帧或双帧输入提供的时序信息不足模型对“已经居中”这个状态不敏感输出在直行和微左微右之间反复横跳如果推理帧率和训练帧率不一致抖动会被放大。解决把k加到 3 或 4给网络更多运动线索在动作输出上加一个长度为 3 的滑动窗口做多数投票或取均值确认clock.tick和采集时一致。这三招一起上抖动基本能压到看不出来。5.5 现象训练几十步后 loss 变成 nan权重全废原因输入没归一化0~255 的像素值乘以初始化权重后激活值巨大softmax 溢出或者学习率过大某一步梯度把权重推到发散区间。也可能只是某张图全黑取对数时踩到了 0。解决确认grab_frame里除以了 255softmax_cross_entropy里先减最大值再取指数在Adam.step之前加一行全局梯度裁剪把范数限制在 5 以内。这三个措施做完还有 nan就打印每层权重的绝对值均值找出第一个变成 inf 的层。6. 二次开发把全连接换成小卷积以及一套可复现的验证流程全连接层把 64×32 的图直接摊平成 4096 维空间结构全丢了模型得靠位置记忆去认弯道。想再进一步最划算的改造是把前两层换成卷积输入 reshape 成(batch, 1, 32, 64)先过 8 个 5×5 卷积步长 2再接 16 个 3×3 卷积然后全局平均池化到 16 维最后接一个小全连接输出三类。参数量从 110 万降到 5 万以内泛化反而更好。改造点有三个。第一反向传播要补卷积层的im2col和列转置代码量会涨到三百行左右如果不想手写可以把卷积层的前向反向用np.lib.stride_tricks.sliding_window_view做一次滑窗展开再复用现成的 Dense 反向逻辑实现上等价于一次矩阵乘。第二输入尺寸变化后回放池里存的旧数据维度对不上改结构必须清空池子重新采集别想着兼容。第三卷积后的特征图尺寸要一层层算清楚(32-5)//21 14再(14-3)//11 12算错一层就会在矩阵乘时报形状不匹配这个错很好定位但很烦。权重持久化用np.savez就够不用引入额外依赖def save_ckpt(path, model, meta): arrays {fW{i}: l.W for i, l in enumerate(model.layers)} arrays.update({fb{i}: l.b for i, l in enumerate(model.layers)}) arrays[meta] np.array([meta[input_dim], meta[k]]) np.savez_compressed(path, **arrays) def load_ckpt(path, model): data np.load(path) for i, layer in enumerate(model.layers): layer.W[...] data[fW{i}] layer.b[...] data[fb{i}]验证方法我固定成一套同一张赛道、同一个起点、固定随机种子跑 10 个回合统计三项指标——完成圈数、平均单圈帧数、撞墙前平均存活帧数。只看“能不能跑完”不够因为模型可能靠贴着护栏蹭过去单圈帧数能反映它是不是真的在走近道。把这套指标写进训练脚本每存一次权重就跑一遍出现指标倒退就回滚到上一版比凭感觉挑 checkpoint 靠谱得多。从那以后我每次动网络结构或者预处理都强制先跑一遍这套固定种子的验证确认三项指标没有退化再继续训练。希望帮到你。本文还有配套的精品资源点击获取
返回列表