从零手写numpy神经网络:射线感知赛车AI与反向传播实战
简介这套Python神经网络赛车游戏实践项目面向希望从零理解神经网络如何操控游戏角色的入门者与项目实战学习者重点解决“只会调库、不懂网络如何接入游戏”的问题。包内共54个文件约8.9MB以16个py源码、10个pyc缓存、9个txt说明和7个xml配置为主另含5张png赛车与地图素材、2段wmv人机/人工操作录屏以及训练模型相关checkpoint、meta、index等文件。资源给出两条清晰路线一条基于TensorFlow框架搭建训练流程另一条在DNN.py中自行实现神经网络并配套游戏逻辑、地图数据、训练数据生成、模型训练与AI测试脚本。已有2064人学习下载读者可据此梳理游戏状态采集、网络训练、模型保存加载到AI操控的完整闭环也能借助录屏对照调试与效果验证适合神经网络入门和课程实践。1. 先让赛车自己开起来这套从零实现神经网络操纵赛车的工程适合谁先说我见过最多的翻车现场有人一上来就把赛车丢给强化学习跑了几万个回合车还在起点附近原地打转奖励曲线平得像一条直线。真正省事的顺序往往反过来——先写一个能稳稳跑圈的规则驾驶员把每一帧的「射线读数 → 转向 油门」录下来再用自己手写的反向传播去拟合这批数据。这套用 python 自己实现神经网络操纵赛车游戏的工程就是这么组织的感知用射线网络是纯 numpy 的几十行全连接层前向、反向、动量更新全部手写训练在 CPU 上跑训完把权重塞回 pygame 窗口就能看到车自己绕圈。适合两类人写过小游戏但没真正推过梯度的人以及背过反向传播公式却从没把它落进一个会动的程序里的人。接下来按「网络怎么搭 → 训练信号哪来 → 主循环怎么接 → 哪里最容易翻车」的顺序拆开讲。2. 感知层与前向传播把 9 条射线塞进一个手写全连接网络2.1 射线传感器为什么不用整帧像素做输入先说感知的选型。用整帧画面比如缩到 84×84 的灰度图做输入当然更「通用」但对一个自己手写反向传播的工程来说代价太高输入维度从十几个跳到七千多个第一层权重矩阵直接膨胀到几十万参数训练样本需求跟着翻好几倍而且你还得自己处理卷积的梯度。射线方案把赛道信息压成几个标量网络规模可控出了问题也能一眼看出是哪条射线读错了。具体做法是从车头中心按固定角度间隔往外打射线遇到赛道边界就停。角度覆盖车头前方 ±90°9 条射线间隔 22.5°最长探测距离 180 像素步进采样 3 像素。距离除以最大长度归一化到 0~1值越小表示那侧越贴墙。再拼上一维归一化速度输入就是 10 维。import math import numpy as np MAX_RAY 180.0 # 射线最大长度单位像素 RAY_STEP 3.0 # 步进采样间隔越小越精确但越慢 def cast_rays(hit_test, pos, heading, n_rays9, fovmath.pi, max_lenMAX_RAY): hit_test(x, y) - bool 判断某个点是否撞墙 返回归一化到 0~1 的距离向量1 表示前方完全空旷 dists [] for i in range(n_rays): # 把 [-fov/2, fov/2] 均匀摊到每条射线上 angle heading - fov / 2 fov * i / (n_rays - 1) d 0.0 hit max_len while d max_len: x pos[0] math.cos(angle) * d y pos[1] math.sin(angle) * d if hit_test(x, y): hit d break d RAY_STEP dists.append(hit / max_len) return np.asarray(dists, dtypenp.float32)这段的关键点在三个参数上RAY_STEP决定精度与开销的平衡3 像素在 180 像素射程下误差不超过 1.7%肉眼看不出来fov取 π 意味着只看前半圆倒车场景不需要n_rays取 9 是经验值7 条时车在急弯会「看不见」弯心11 条以上收益迅速衰减但每帧多出来的碰撞检测很实在。归一化那一步不能省原始像素距离在 0~180 之间直接喂进网络会让第一层的梯度尺度失衡这是后面第 5 章要专门讲的一个坑。2.2 手写 Dense / ReLU 层与前向图网络结构定成 10 → 64 → 32 → 2两层隐藏层都用 ReLU参数量一共 2850 个普通笔记本上单次前向不到 20 微秒。层输入维度输出维度激活参数量隐藏层 11064ReLU704隐藏层 26432ReLU2080输出层322tanh / sigmoid66层要拆成对象因为反向传播时必须拿到前向时的输入和激活掩码写成纯函数就得在外面手动存一堆中间变量很容易对错顺序。class Dense: def __init__(self, n_in, n_out, rng): # He 初始化适配 ReLU方差取 2/n_in避免深层激活塌缩 self.W rng.normal(0.0, np.sqrt(2.0 / n_in), size(n_in, n_out)) self.b np.zeros(n_out) 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 # dL/dW x^T · dL/dz self.db grad_out.sum(axis0) return grad_out self.W.T # 传给上一层的梯度 class ReLU: def forward(self, x): self.mask x 0 # 存掩码不用重算 return x * self.mask def backward(self, grad_out): return grad_out * self.mask # 负半轴梯度直接归零forward里把输入缓存到self.xbackward里算dW x^T · grad_out这是整个工程里最容易写错的矩阵维度——x形状是(batch, n_in)grad_out是(batch, n_out)转置后乘出来正好是(n_in, n_out)和W同形。我建议第一次写的时候在backward里加一句assert self.dW.shape self.W.shape比事后对着形状报错猜半天强。整个前向图串起来是这样MLP类负责按顺序调用并保存层引用class MLP: def __init__(self, sizes, rng): self.layers [] for i in range(len(sizes) - 1): self.layers.append(Dense(sizes[i], sizes[i 1], rng)) if i len(sizes) - 2: self.layers.append(ReLU()) def forward(self, x): for layer in self.layers: x layer.forward(x) return x # 输出层之后不做激活交给解码函数 def backward(self, grad): for layer in reversed(self.layers): grad layer.backward(grad)sizes传[10, 64, 32, 2]就自动串出三层最后一个Dense后面不挂 ReLU因为输出层要用 tanh 和 sigmoid得放到解码函数里做。这个设计让网络和动作语义解耦后面想改成离散动作左/直/右三分类只需要换解码和损失前面的层不用动。2.3 输出层动作解码与归一化的边界输出的两个数不是直接当转向角和油门用。第一个数过 tanh 映射到 -1~1代表方向盘打到底的比例第二个数过 sigmoid 映射到 0~1代表油门开度。之所以不在网络里直接加激活是因为反向传播时要把激活导数一起算进链式法则里放在解码函数里逻辑更清晰也能在推理阶段单独替换比如加低通滤波。def decode(out): steer np.tanh(out[..., 0]) # -1 左满舵1 右满舵 throttle 1.0 / (1.0 np.exp(-out[..., 1])) # 0 松油门1 全油门 return steer, throttle def decode_backward(d_steer, d_throttle, out): steer np.tanh(out[..., 0]) throttle 1.0 / (1.0 np.exp(-out[..., 1])) g0 d_steer * (1.0 - steer ** 2) # tanh g1 d_throttle * throttle * (1.0 - throttle) # sigmoid return np.stack([g0, g1], axis-1)边界要写清楚np.exp在输入很负时会溢出成 inf虽然 sigmoid 数学上趋近 0但 numpy 会给你警告甚至 nan所以推理前把输出层原始值裁剪到 ±30 是个便宜的保险。另外油门没有倒车档如果车头抵墙卡死网络唯一能学会的自救方式是原地打方向蹭出来这也是为什么训练数据里必须包含这类贴墙状态否则实车一卡就再也出不来——第 5 章第一条坑说的就是这个。3. 训练信号的来源没有标签时怎么构造损失和反向传播3.1 用纯追踪算法当「教师」生成伪标签监督学习需要标签但赛车游戏里没人告诉你「此刻方向盘该打多少度」。可行的做法是先写一个规则驾驶员当教师在赛道中心线上找车最近的点再沿中心线往前推一段看距lookahead得到目标点然后按车头朝向与目标点方向的夹角决定转向。这套纯追踪pure pursuit逻辑不需要训练参数调对了就能稳定跑圈唯一的缺点是它依赖中心线数据换一条没标注的赛道就失效——而神经网络学的是「射线 → 动作」的映射学成之后就不再需要中心线了。def teacher_action(car, centerline, lookahead90.0, wheelbase34.0): # 1) 找中心线上离车最近的点 d np.hypot(centerline[:, 0] - car.x, centerline[:, 1] - car.y) i0 int(np.argmin(d)) # 2) 沿中心线往前走 lookahead 像素得到目标点 acc, j 0.0, i0 while acc lookahead: j (j 1) % len(centerline) acc np.hypot(*(centerline[j] - centerline[j - 1])) tx, ty centerline[j] # 3) 目标点转到车体坐标系算横向偏差 dx, dy tx - car.x, ty - car.y local_x dx * math.cos(-car.heading) - dy * math.sin(-car.heading) local_y dx * math.sin(-car.heading) dy * math.cos(-car.heading) alpha math.atan2(local_y, local_x) steer math.atan2(2 * wheelbase * math.sin(alpha), lookahead) return float(np.clip(steer / 0.6, -1.0, 1.0)), 0.85 # 转向归一化 固定油门lookahead是最重要的参数太小车会左右画龙太大在急弯会切到内墙。90 像素配合 34 像素轴距在半径 100 像素左右的弯道上表现稳定。转向输出除以 0.6 做归一化因为车辆物理里最大前轮转角就设的 0.6 弧度保证标签落在 tanh 的值域内不会出现网络永远拟合不到的目标值。采集时不要只跑完美圈。每个回合按概率给转向叠加一点噪声让车偶尔偏离中心线甚至蹭一下墙把这些「偏离状态」也录进数据集。原因很直接如果训练集里全是赛道正中央的漂亮轨迹网络就永远没见过「贴墙 15 像素」这种输入实车一旦偏出去就会做出完全错误的决策然后越错越远。这个思路和模仿学习里的 DAgger 是一个意思只是实现上简化成了「加噪声 混合教师动作」。3.2 手推梯度MSE 损失穿过 tanh 与 ReLU转向是连续值用均方误差油门也用均方误差两项加权合成总损失。权重给转向 1.0、油门 0.3因为转向错了直接撞墙油门小一点最多是跑得慢。def loss_and_grad(pred, target, w_steer1.0, w_throttle0.3): steer_p, thr_p decode(pred) steer_t, thr_t target[..., 0], target[..., 1] n pred.shape[0] # 损失值只为打印观察用 loss (w_steer * np.mean((steer_p - steer_t) ** 2) w_throttle * np.mean((thr_p - thr_t) ** 2)) # 手工链式dL/dpred dL/dout · dout/dpred d_steer w_steer * 2.0 * (steer_p - steer_t) / n d_thr w_throttle * 2.0 * (thr_p - thr_t) / n grad decode_backward(d_steer, d_thr, pred) return float(loss), grad容易错的地方在这里decode_backward接受的d_steer是「损失对激活后输出」的梯度返回的是「损失对激活前原始输出」的梯度中间乘了 tanh 和 sigmoid 的导数。如果忘了这一步直接把pred - target当作梯度回传网络依然能收敛一部分但收敛速度会明显变慢而且在饱和区tanh 输出接近 ±1梯度不会自动衰减容易震荡。我第一版就是这么写的训练 loss 能降但车在弯心会突然打满方向查了两天才发现少了激活导数。剩下的链路就是标准的反向传播grad依次穿过输出层 Dense、ReLU、隐藏层 Dense每层用backward算自己的dW/db并把梯度传下去。这里没什么花活唯一要盯的是批次维度的求和位置——db要沿 batch 求和dW是矩阵乘不是逐元素乘。3.3 参数更新、动量与学习率衰减优化器不用 Adam手写一个带冲量的 SGD 就够了参数量才两千多Adam 的优势体现不出来反而多两个状态变量要维护。class SGDM: def __init__(self, layers, lr3e-3, momentum0.9): self.layers, self.lr, self.mu layers, lr, momentum self.vW [np.zeros_like(l.W) for l in layers] self.vb [np.zeros_like(l.b) for l in layers] def step(self, lrNone): lr self.lr if lr is None else lr for i, l in enumerate(self.layers): self.vW[i] self.mu * self.vW[i] - lr * l.dW self.vb[i] self.mu * self.vb[i] - lr * l.db l.W self.vW[i] l.b self.vb[i]学习率起始 3e-3每 20 个 epoch 乘 0.5训 60 个 epoch 差不多收敛。冲量 0.9 是为了抹平批次之间的方向抖动尤其是「直道样本」和「弯道样本」混在同一个 batch 里时梯度方向差异很大纯 SGD 会来回摆。学习率给大了比如 1e-2第一批就可能让 loss 直接飙到 nan因为输出层饱和后 sigmoid 导数趋近 0梯度虽然小了但权重更新幅度在初期会被大梯度带偏这个现象在第 5 章会展开。判断收敛不看单个 batch 的 loss要看每轮结束在验证集上的表现。验证集从不同赛道段采不要和训练集同分布否则你会得到一个「在训练赛道上完美、换条赛道就撞墙」的模型。4. 训练循环与游戏主循环对接定步长、经验缓存与评估4.1 把 pygame 帧循环改成定步长物理步进游戏主循环最常见的写法是clock.tick(60)然后每帧按固定速度更新位置。问题是tick只保证「不超过 60 帧」实际帧间隔会随机器负载浮动物理积分就变成了变步长同样的转向输入在不同机器上跑出的轨迹完全不同。训练数据里混进这种不确定性网络学到的东西就是噪声。改成定步长累加器不管渲染多快物理固定按 1/60 秒推进渲染只负责画。DT 1.0 / 60.0 # 物理步长固定不随帧率变化 MAX_STEPS 60 * 120 # 单回合上限 120 秒防止卡死时无限跑 def run_episode(env, policy, recordNone, renderTrue): state env.reset() total_reward, steps 0.0, 0 while steps MAX_STEPS: action policy(state) # 网络或教师策略 next_state, reward, done, info env.step(action, DT) if record is not None: record.append((state, action)) # 只存 (输入, 标签)省内存 state next_state total_reward reward steps 1 if render: env.draw() if done: # 撞墙或跑完一圈 break return total_reward, steps, infoDT固定成 1/60 之后同一份权重在任何机器上跑出来的轨迹基本一致这一点在做对比实验时很关键。MAX_STEPS是后悔药策略退化时车可能贴着墙原地磨没有步数上限的话一次评估能挂十分钟。如果要做加速训练把render关掉同时把env.draw()里的表面翻转整个跳过物理照常推进。无渲染模式下单回合能跑到几千帧每秒采集一万条样本也就几分钟。4.2 经验采集、批采样与训练集构建数据格式定成两个 numpy 数组X形状(N, 10)Y形状(N, 2)直接存.npz加载比逐条反序列化快一个数量级。采集分三轮每轮用当前模型自己跑而不是一直用教师def collect(model, env, n_episodes40, noise0.15, rngNone): X, Y [], [] for ep in range(n_episodes): state env.reset() for _ in range(MAX_STEPS): t_steer, t_thr teacher_action(env.car, env.centerline) # 混合策略模型动作 噪声 教师兜底保证不跑飞 if model is not None and rng.random() 0.7: raw model.forward(state[None, :])[0] steer, _ decode(raw) steer float(np.clip(steer rng.normal(0, noise), -1, 1)) else: steer float(np.clip(t_steer rng.normal(0, noise), -1, 1)) state, _, done, _ env.step((steer, t_thr), DT) X.append(state) # 存的是执行动作之后看到的状态 Y.append((t_steer, t_thr)) # 标签始终是教师动作 if done: break return np.asarray(X, np.float32), np.asarray(Y, np.float32)这里有个细节值得说X存的是「当前状态」Y存的是「教师在该状态会做的动作」哪怕实际执行的是带噪声的动作。这样即使车被噪声带偏到奇怪的位置只要那个位置在画面里出现了就有一条「这个位置应该怎么打方向」的监督信号相当于在线的数据增广。三轮下来数据量从几千涨到几万覆盖的偏离状态会明显变多。批采样用rng.permutation打乱索引每个 batch 64 条。不要按回合顺序取否则一个 batch 里全是同一段赛道的样本梯度方向严重偏斜。4.3 评估指标与权重落盘格式评估不能只看 loss。跑 5 条固定种子的赛道统计三个数平均完成距离撞墙前跑了多远、平均单圈时间、撞墙率。这三个指标里最敏感的是完成距离模型稍微退化它就先掉。def evaluate(model, envs, n_episodes5): stats {dist: [], time: [], crash: 0} for env in envs: for _ in range(n_episodes): _, steps, info run_episode(env, lambda s: infer(model, s), renderFalse) stats[dist].append(info[distance]) stats[time].append(steps * DT) stats[crash] int(info[crashed]) return {k: float(np.mean(v)) if isinstance(v, list) else v for k, v in stats.items()}权重存.npz时必须一起存元数据每层的W.shape、输入维度、射线数量和最大射程、动作归一化系数。少了这些下次加载时形状对不上报错信息只会告诉你矩阵乘法维度不匹配根本看不出是射线从 9 条改成了 11 条。def save_model(path, model, meta): arrays {fW{i}: l.W for i, l in enumerate(model.dense_layers())} arrays.update({fb{i}: l.b for i, l in enumerate(model.dense_layers())}) arrays[meta] np.array(json.dumps(meta)) np.savez_compressed(path, **arrays)5. 避坑与排查五处最容易翻车的地方5.1 训练 loss 降到 0.001实车却贴着墙磨现象离线验证集上的 MSE 低到小数点后三位把权重接进游戏车一出发就往护栏上蹭蹭住之后不再动。原因训练数据全部来自教师策略跑出的「好状态」网络只见过赛道中央附近的输入分布。实车一旦因为初始位置偏移或帧间抖动偏出几像素就会进入训练集里从未出现的状态空间输出一个毫无意义的动作然后偏得更多形成正反馈。解决采集阶段就注入偏离。具体做法是 4.2 里那段混合策略——按 30% 概率执行教师动作并叠加 0.15 的高斯噪声让车主动撞几次墙把这些状态也录进去。另外在回合开始时随机化初始位置和朝向横向偏移 ±25 像素、角度偏移 ±0.2 弧度覆盖范围比手动摆位置靠谱得多。5.2 第二个 epoch 就出现 nanloss 打印成 inf现象第一个 epoch 正常下降第二个 epoch 中途 loss 变成 nan之后所有参数都是 nan训练彻底废掉。原因两个问题叠加。一是输入没归一化原始射线距离在 0~180进网络后第一层激活值极大ReLU 之后梯度爆炸二是学习率给到了 1e-2爆炸的梯度一步就把权重推到发散区sigmoid 饱和后exp溢出。解决输入侧强制除以MAX_RAY速度除以最大速度保证所有特征落在 0~1学习率从 3e-3 起前 200 个 step 做线性 warmup在优化器step之前加梯度裁剪把全局范数限制在 5.0 以内。三件套一起上之后这个现象基本不再出现。def clip_grads(layers, max_norm5.0): total math.sqrt(sum(float((l.dW ** 2).sum() (l.db ** 2).sum()) for l in layers)) if total max_norm: scale max_norm / (total 1e-8) for l in layers: l.dW * scale l.db * scale5.3 换台机器训练结果完全不一样同一份代码跑不出同样曲线现象台式机上训出来的模型能跑完两圈笔记本上同样的代码训出来第一圈就撞墙loss 曲线形状都不一样。原因物理步进耦合了渲染帧率。用clock.tick(60)时性能差的机器掉到 40 帧每帧dt变大车辆积分出来的转角和位移全变了采集到的轨迹和标签跟着变等于换了一个环境在训。解决按 4.1 的做法改成固定DT的累加器渲染和物理彻底解耦。改完之后同一份代码在不同机器上的评估指标差异能压到 5% 以内。另外随机种子要显式传进去np.random.default_rng(seed)而不是用全局随机状态否则数据打乱的顺序都不可复现。5.4 离线评估很准接进实时循环就乱打方向现象加载保存的权重跑评估脚本指标漂亮把同一份权重接进游戏主循环车左右乱摆方向盘像抽风。原因特征顺序错位。训练时输入是[9 条射线, 速度]推理时有人写成[速度, 9 条射线]形状都是 10 维程序不报错但网络看到的完全是另一组数。另一种可能是射线角度方向反了训练时负角度在左推理时负角度在右。解决把特征拼装抽成一个函数build_state(rays, speed)训练和推理都调它绝不在两处各写一遍。加载权重时校验W0.shape[0] 10不匹配直接抛异常。射线角度的符号约定写进元数据加载时一并读出来用。5.5 车在直道上左右抽搐像在不停地修正现象直道上明明没有弯车的方向盘却在 ±0.1 之间高频抖动速度上不去看着像喝多了。原因逐帧独立决策每帧的输出没有时序约束。相邻两帧的射线读数只差零点几个像素但网络输出可能因为输入落在 ReLU 的分段边界两侧而跳变。解决在推理侧加一阶低通滤波steer 0.7 * prev_steer 0.3 * raw_steer系数 0.7 相当于时间常数约 3 帧的平滑直道抖动基本消失弯道响应延迟人眼感知不到。更彻底的做法是把上一帧动作也拼进输入向量让网络自己学平滑但这会让输入维度变成 12得重训属于进阶选项。6. 推理加速与可视化调试把训练好的权重塞回实时循环训练跑通只是第一步真正让人抓狂的是调参阶段——你想知道网络为什么在某个弯打错方向光看 loss 是看不出来的。这时候可视化比什么都值钱。最省事的做法是把三条射线和网络输出直接画在车上射线用青色细线撞墙点画个红点方向盘角度画成一根绕车头旋转的短线油门画成车尾的火焰长度。这几笔加进去不到三十行 pygame 代码但能立刻分辨出「是射线读错了」还是「网络决策错了」。我遇到过一条射线因为碰撞检测的浮点误差穿过薄墙画面上一看就发现那条线穿墙而过而在数据里它表现为一个恒定的 1.0网络当然学不明白。第二层可视化是权重热力图。把第一层 10×64 的权重按输入维度求绝对值均值画成一条 10 格的横条就能看出网络到底在关注哪几个特征。正常情况下 9 条射线的权重应该比较均匀速度那一维偏低如果只有正前方那条射线权重特别高说明训练数据里绝大多数是直道模型退化成「只看前面」这种模型在急弯必翻车。第三层是加速。关掉渲染的 headless 模式配合固定步长单回合能跑到几千帧每秒用来做批量评估。注意关渲染时要跳过pygame.display.flip()和整个绘制调用只保留物理和射线检测不要只把窗口隐藏——隐藏窗口的绘制照样占 CPU加速比会差一个数量级。再给一个我常用的技巧权重加载时做形状指纹校验。保存时把每层形状拼成一个字符串存进元数据加载时逐个比对不一致就打印出「期望 10→64实际 12→64」这种可读信息。少了这一步改一次射线数量就得靠猜报错原因。def load_model(path, model): data np.load(path, allow_pickleFalse) meta json.loads(str(data[meta])) expect [int(x) for x in meta[sizes]] got [model.layers[0].W.shape[0]] [l.W.shape[1] for l in model.dense_layers()] assert expect got, f结构不匹配: 期望 {expect}, 实际 {got} for i, l in enumerate(model.dense_layers()): l.W[...] data[fW{i}] l.b[...] data[fb{i}] return meta最后一个习惯任何一次参数改动先跑 3 个种子的短评估指标没有明显下降再放开跑完整训练。我曾经为了省这五分钟直接训了四十分钟结果发现改错的是输入归一化的除数整批权重全废——从那以后我每次动感知层或归一化系数都强制先走一遍短评估和形状校验。希望这套流程帮到你。本文还有配套的精品资源点击获取