☰
手写NumPy神经网络:游戏AI从行为克隆到闭环驾驶
2026/10/28 18:22:20 网站建设 项目流程

简介:这是一份面向神经网络入门者与Python游戏AI爱好者的实战项目包,围绕“用神经网络操纵赛车游戏”这一目标,提供从数据采集、模型训练到自动驾驶测试的完整闭环。压缩包共54个文件,约8.9MB,以16个Python源码和10个字节码文件为核心,辅以9个文本说明、7个XML配置、5张PNG素材以及2段WMV演示视频,并包含模型检查点、训练数据与地图资源,便于直接运行和二次修改。项目同时给出基于TensorFlow框架与纯手写DNN两条实现路线,涵盖激活函数、模型训练、数据生成、游戏逻辑、AI测试与人机对比等模块,目录划分清晰。已有2064人学习下载,读者可据此理解神经网络如何从游戏画面或状态输入生成控制指令,掌握训练集构造、模型保存加载、测试评估与排错思路,也可借助演示视频和地图素材复现实验场景。

1. 从零写一个能开车的神经网络,难的从来不是网络本身

第一次把「游戏 + 神经网络 + 自己实现神经网络」这三件事凑到一起时,我以为拦路虎是反向传播那几行链式求导。真跑起来才发现,整个网络只有七十几行 numpy,剩下的两千行全在跟数据管线较劲:截屏慢了三帧、画面和按键差了一拍、训练集里九成样本都是直行……车一头撞上护栏的时候,模型往往没算错,是喂进去的东西本来就不对。这套方案完全不依赖深度学习框架,用 numpy 手写前向与反向,把赛车画面压成灰度小图送进三层全连接,输出一个方向盘角度,再用按键注入把预测值送回游戏闭环跑。适合已经会写 Python、想弄明白「像素到底怎么变成动作」的开发者,也适合拿它当课程设计或自娱项目落地的人。

2. 拆开黑匣子:三层网络怎么把一帧画面变成方向盘角度

在动手之前先把结构定死,后面所有脚本都围绕这个结构写。整条链路是:截屏 → 裁剪 → 灰度 → 缩放 → 归一化 → 展平 → 前向 → 输出角度 → 平滑 → 注入按键。任何一环的尺寸或数值范围变了,训练出来的权重就废了,这也是后面反复强调「训练与推理必须同源」的原因。

2.1 输入层:为什么把画面压到 48×48 灰度

原始截屏动辄 1920×1080×3,直接展平是六百多万维,第一层权重的参数量会爆炸,普通笔记本根本训不动。我一般会先裁掉 HUD、小地图和天空这类无关区域,只留车头前方的路面,再转灰度、缩到 48×48,展平后是 2304 维。灰度不是偷懒:赛道、护栏、路面标线的区分主要靠明暗和纹理,颜色对判断转向的贡献很小,去掉色彩反而降低了模型对光照变化的敏感度。

缩放到 48×48 用的是面积插值,而不是默认的双线性。面积插值相当于对每个目标像素覆盖的源区域做平均,画面里的细碎噪点会被抹平,留下的是大块的路面结构,这对全连接网络更友好。缩放比例也别乱改,训练时用 48,推理时用 64,模型会直接失灵,因为它学到的权重对应的是固定位置的像素。

还有一点必须提前想清楚:单帧画面里没有速度信息。同一张图,车速 30 和车速 120 的前瞻距离完全不同,但网络看到的输入一模一样。这也是为什么后面要引入帧堆叠或者速度反馈,单帧模型在低速弯能看,上了高速就会切内线撞墙。

2.2 前向传播:三行矩阵乘法加两个激活函数

网络结构定为 2304 → 128 → 64 → 1,隐藏层用 ReLU,输出层线性。隐藏层宽度不用贪大,128 和 64 这两层在这个任务上已经能拟合得不错,再宽只会让训练变慢、过拟合变重。权重初始化用 He 初始化,标准差取 sqrt(2/fan_in),专门配合 ReLU,能明显减少训练初期梯度消失的概率。

import numpy as np def init_params(seed=42): rng = np.random.default_rng(seed) # He 初始化:适配 ReLU,fan_in 分别是 2304 / 128 / 64 W1 = rng.normal(0, np.sqrt(2 / 2304), (128, 2304)); b1 = np.zeros((128, 1)) W2 = rng.normal(0, np.sqrt(2 / 128), (64, 128)); b2 = np.zeros((64, 1)) W3 = rng.normal(0, np.sqrt(1 / 64), (1, 64)); b3 = np.zeros((1, 1)) return {"W1": W1, "b1": b1, "W2": W2, "b2": b2, "W3": W3, "b3": b3} def forward(p, X): # X 形状 (2304, N),一列是一帧 z1 = p["W1"] @ X + p["b1"]; a1 = np.maximum(z1, 0) # ReLU z2 = p["W2"] @ a1 + p["b2"]; a2 = np.maximum(z2, 0) # ReLU y = p["W3"] @ a2 + p["b3"] # 线性输出 return y, (X, z1, a1, z2, a2)

X采用「特征在行、样本在列」的排布,一次前向能算完整个 batch,省掉 Python 层的循环。cache里存的是反向传播要复用的中间量,尤其是z1、z2,ReLU 的导数要靠它们判断哪些位置被激活过。输出层不加激活,是因为这里做的是回归,角度本身可以是负数,套一个 sigmoid 会把输出压死在 0 到 1 之间,右转的样本永远学不对。

2.3 输出层设计:回归角度还是分类按键

另一种常见写法是把动作离散成「左、右、直行、加速、刹车」几类做分类。两种思路我都试过,差别比想象中大。

对比项回归角度分类按键
输出维度1(连续值 -1 到 1)4 到 6(softmax)
标签来源录制时的模拟摇杆量或按键组合折算直接记录按下了哪个键
抖动表现输出连续,配合低通滤波很平滑相邻帧类别跳变,方向容易来回摆
训练难度对标签归一化敏感收敛快,但对边界情况判断粗糙
适合场景有模拟量输入、需要细腻转向纯键盘游戏、只要求能跑完赛道

回归的短板是对标签尺度极其敏感:如果标签是 -180 到 180 的角度,输出层梯度会非常大,学习率稍微大一点就发散。解决办法是训练前把角度除以最大转角,统一压到 -1 到 1,推理时再乘回去。分类的短板是失去了「转多少」的信息,模型只知道该左转,不知道左转 5 度还是 40 度,跑起来像新手第一次摸方向盘。

我这里统一按回归写,标签用steer / 45.0归一化,最大转角设 45 度。

2.4 反向传播与 Adam:手推四个梯度公式

损失函数用均方误差,因为输出是连续量,MSE 的梯度干净、好推。整个反向过程本质上是链式法则走一遍,注意除以 batch 大小 N,否则梯度会随 batch 变化,学习率就失去了意义。

def backward(p, cache, y_pred, y_true): X, z1, a1, z2, a2 = cache N = X.shape[1] dy = 2 * (y_pred - y_true) / N # MSE 对输出的梯度 gW3 = dy @ a2.T; gb3 = dy.sum(axis=1, keepdims=True) da2 = p["W3"].T @ dy dz2 = da2 * (z2 > 0) # ReLU 导数:激活为正则梯度透传 gW2 = dz2 @ a1.T; gb2 = dz2.sum(axis=1, keepdims=True) da1 = p["W2"].T @ dz2 dz1 = da1 * (z1 > 0) gW1 = dz1 @ X.T; gb1 = dz1.sum(axis=1, keepdims=True) return {"W1": gW1, "b1": gb1, "W2": gW2, "b2": gb2, "W3": gW3, "b3": gb3}

(z > 0)生成的布尔数组在 numpy 里会自动转成 0/1,正好是 ReLU 的导数,比写np.where更省事。偏置的梯度沿样本维度求和,keepdims=True保证形状是(out, 1)而不是被压成一维,否则和参数相加时会触发广播错误。

优化器用 Adam,对学习率不敏感这一点在实验阶段特别省心。手写版本只需要维护一阶和二阶矩估计:

def adam_step(p, grads, state, lr=1e-3, t=1, beta1=0.9, beta2=0.999, eps=1e-8): for k in p: g = grads[k] state.setdefault("m" + k, np.zeros_like(p[k])) state.setdefault("v" + k, np.zeros_like(p[k])) state["m" + k] = beta1 * state["m" + k] + (1 - beta1) * g state["v" + k] = beta2 * state["v" + k] + (1 - beta2) * (g ** 2) # 偏差校正,t 从 1 开始计数 m_hat = state["m" + k] / (1 - beta1 ** t) v_hat = state["v" + k] / (1 - beta2 ** t) p[k] -= lr * m_hat / (np.sqrt(v_hat) + eps)

偏差校正是 Adam 里最容易被漏掉的一步。少了它,前几十步的二阶矩估计严重偏小,更新步长会异常大,表现就是训练头两个 epoch 损失先炸一下再降。t必须从 1 开始单调递增,中断续训时要把它和state一起存下来,不然校正系数从头算,模型会突然抖一下。

3. 数据从哪来:手动驾驶录制与样本清洗

网络结构定了,下一个问题是标签从哪来。强化学习听起来高级,但奖励函数设计不好就是玄学,几百个 episode 下来车还在原地打转。我更推荐先做行为克隆:自己开着录一遍,把「画面 → 当时的转向量」存成成对的样本,当成监督学习来训。这条路的优点是调试链条短,损失降不下去就是数据或预处理的问题,不会像强化学习那样所有因素搅在一起。

3.1 用 pygame 搭一个可复现的赛车环境

直接对着商业游戏窗口录数据,最大的问题是不可复现:赛道随机、对手干扰、帧率波动,同一段路跑十次样本质量完全不同。我的做法是先写一个俯视视角的简易赛车环境,物理模型足够简单,但保留转向、加速、偏离赛道判定这些关键要素,用它把整条链路跑通,再迁移到真实窗口。

import pygame, math W, H = 800, 600 CAR_L, CAR_W = 26, 14 def step(car, steer, throttle, dt=1/60): # steer ∈ [-1, 1] 映射到最大 45 度转角 car["theta"] += steer * math.radians(45) * dt * (0.4 + 0.6 * car["v"]) car["v"] = max(0.0, min(1.0, car["v"] + (throttle - 0.3) * dt)) car["x"] += math.cos(car["theta"]) * car["v"] * 260 * dt car["y"] += math.sin(car["theta"]) * car["v"] * 260 * dt # 出界判定:赛道用一个环形矩形带近似 return car

dt固定成 1/60 并在循环里用固定步长推进,是为了让样本之间的时间间隔恒定。真实游戏里帧率一抖,同样的转向量对应的位移就变了,模型会学到一堆自相矛盾的标签。0.4 + 0.6 * car["v"]这一项模拟的是速度越快转向越迟钝的手感,加上它之后模型学到的角度分布更接近真实赛车,迁移时不用重新调。

出界判定不要用像素级碰撞,直接判断车心到赛道中心线的距离是否超过半宽,简单且稳定。判定成立时立刻重置车辆位置,避免连续几十帧全是撞墙画面被录进数据集。

3.2 录制脚本:把帧和标签写进同一个 npz

录制的关键是同步。画面和按键必须在同一帧内取,中间隔一次pygame.event.pump()都可能错位。我一般把预处理函数直接内联在采集循环里,保证存下来的已经是 2304 维向量,训练脚本不需要再做任何转换。

import numpy as np, cv2 def preprocess(frame_bgr, size=48): gray = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2GRAY) small = cv2.resize(gray, (size, size), interpolation=cv2.INTER_AREA) return (small.astype(np.float32) / 255.0).ravel() # 2304 维,0~1 def collect(env, keys, max_frames=40000): X, y = [], [] for _ in range(max_frames): steer, throttle = keys() frame = env.render() # 与物理步进同一帧 X.append(preprocess(frame)) y.append(steer / 45.0) # 归一化到 -1~1 env.step(steer, throttle) np.savez_compressed("drive_data.npz", X=np.stack(X).astype(np.float32), y=np.array(y, dtype=np.float32))

preprocess里的除以 255 必须写死在采集端,训练端和推理端共用同一个函数,不要各写一份。实际踩过的坑是训练脚本里写X / 255.0、推理脚本里忘了除,离线验证损失 0.003,上线后车直接冲出赛道,查了半天才定位到这一行。

保存成npz压缩格式而不是逐帧 PNG,一是省 IO,二是避免文件名排序和采集顺序不一致。四万帧的数据压完大概几十兆,加载一次不到一秒。采集过程中建议每五千帧打印一次已录帧数和当前出界次数,出界率超过 20% 说明这段数据质量不行,趁早重录。

3.3 样本清洗:直行帧下采样与镜像增广

录完的数据不能直接拿来训。手动驾驶时人大部分时间在直道上,转向量接近 0 的帧可能占到七成,模型只要学会「永远输出 0」就能拿到很低的平均损失,结果就是车只会直着开,遇到弯道直接撞墙。这是行为克隆最典型的失败模式。

处理办法是按角度分桶,对接近直行的样本做下采样。把 -1 到 1 均分成 20 个桶,统计每个桶的样本数,以中位数为基准,超过两倍的桶随机丢弃到两倍,不足的桶保持原样并在训练时给更高权重。

def balance(X, y, bins=20, cap_ratio=2.0): idx = np.clip(((y + 1) / 2 * bins).astype(int), 0, bins - 1) counts = np.bincount(idx, minlength=bins) cap = int(np.median(counts[counts > 0]) * cap_ratio) keep = [] rng = np.random.default_rng(0) for b in range(bins): ids = np.where(idx == b)[0] if len(ids) > cap: ids = rng.choice(ids, cap, replace=False) keep.append(ids) keep = np.concatenate(keep) return X[keep], y[keep]

cap_ratio设 2.0 是个经验值:压得太狠,直行样本不足,车在直道上会左右画龙;压得太松,弯道样本被淹没。改成 1.5 会更激进,适合弯道密集的赛道。

镜像增广是几乎零成本的翻倍手段:画面水平翻转,转向量取反。注意只对画面做翻转,不要连同裁剪区域一起翻,否则车头前方的路面位置会错位。翻转后直行样本还是直行,不会引入新的偏差。

3.4 训练循环与学习率:从 1e-3 到 1e-4

训练循环本身很短,重点在于验证集的划分方式。不能随机打乱后切分,因为相邻帧高度相似,随机切分会让验证集里混进训练集的近邻,指标虚高。正确做法是按时间段切:前 80% 连续帧做训练,后 20% 做验证。

from numpy import load d = load("drive_data.npz") X, y = d["X"], d["y"] X, y = balance(X, y) # 先做类别均衡 split = int(len(X) * 0.8) Xtr, ytr = X[:split].T, y[:split][None, :] # 转成 (2304, N) / (1, N) Xva, yva = X[split:].T, y[split][None, :] p = init_params() state = {} t = 0 for epoch in range(30): perm = np.random.permutation(Xtr.shape[1]) for i in range(0, len(perm), 64): b = perm[i:i + 64] yp, cache = forward(p, Xtr[:, b]) g = backward(p, cache, yp, ytr[:, b]) t += 1 lr = 1e-3 if epoch < 20 else 1e-4 # 后 10 轮降学习率 adam_step(p, g, state, lr=lr, t=t)

前 20 轮用 1e-3 快速下降,后 10 轮切到 1e-4 精修,这个分段策略比余弦退火更省事,效果差距不大。batch 取 64 是在内存和梯度稳定性之间的折中,机器内存紧张可以降到 32,但学习率要相应减半。

训练过程中每个 epoch 结束后在验证集上算一次 MSE,同时把预测角度还原成度数,看看最大偏差。MSE 降到 0.002 以下通常就够用了,再往下压大概率是过拟合。如果验证损失在第 5 个 epoch 之后开始回升,直接停,别等 30 轮跑完。

4. 让模型真正操控游戏:推理循环与按键注入

权重训完只是拿到了一个函数,真正让它开车的是推理循环。这一段最容易翻车,因为训练是离线的、可重复的,推理是在线的、每一帧都可能出意外。整个循环的节奏必须稳住:抓图、预处理、前向、平滑、注入,任何一步耗时波动过大,车的行为就会变得不可预测。

4.1 截屏采样:保证和训练时同源

如果训练数据来自自建环境,推理也在同一个环境里跑,直接调env.render()就行。要迁移到外部窗口,就得用屏幕抓取。mss的抓取速度比 PIL 的ImageGrab快不少,单次几十毫秒,勉强能跟上 30 帧。

import mss, numpy as np sct = mss.mss() region = {"left": 320, "top": 180, "width": 960, "height": 540} # 需与训练时裁剪一致 def grab(): shot = np.array(sct.grab(region)) # BGRA return shot[:, :, :3] # 丢弃 alpha,得到 BGR

region的四个值必须和录制时用的完全一致,包括宽高比。训练时裁的是 960×540,推理时裁成 800×450,缩放之后画面内容的相对位置就变了,模型看到的「路面在画面中的位置」和学到的对不上,表现是车总是偏向一侧。

抓屏频率不要盲目追高。30 帧已经足够,再往上单帧计算时间占比变大,循环周期反而不稳。实测下来固定 30 帧、每帧 sleep 补足剩余时间,比不限制频率但实际在 22 到 45 之间跳动要稳得多。

提示:抓屏前先确认窗口没有被其他窗口遮挡,抓到的就是遮挡后的画面,模型会把它当成赛道。

4.2 推理循环与角度平滑:一阶低通滤波

模型逐帧独立预测,相邻帧的输出会有小幅抖动,直接注入按键的表现是方向盘高频左右微摆,车走不成直线。加一个一阶低通滤波就能压住,代价是响应延迟增加几帧,需要和滤波系数一起权衡。

alpha = 0.6 prev = 0.0 def predict_steer(frame, p): global prev x = preprocess(frame)[:, None] # (2304, 1) out, _ = forward(p, x) raw = float(out[0, 0]) prev = alpha * raw + (1 - alpha) * prev # 越大越跟手,越小越平滑 return max(-1.0, min(1.0, prev))

alpha取 0.6 时,阶跃输入的响应大概三四帧到位,直道上的抖动基本消失,进弯时也不会明显滞后。赛道弯道很急的话可以提到 0.75,代价是直道末端会有一点残余摆动。

输出还要做一次裁剪。模型在没见过的画面上可能吐出 1.8 这种值,不裁剪的话按键映射会算出越界的键位组合,直接导致按键卡死。

4.3 按键注入与焦点管理

键盘注入看起来最简单,实际坑最多。pynput在部分窗口里完全无效,因为它发出的是应用层事件,而不少游戏读的是底层输入。这种情况下换成pyautogui也未必管用,更稳的路子是用系统级输入接口,或者干脆给游戏接一个虚拟手柄,把手柄的模拟量直接写进去。

from pynput.keyboard import Controller, Key kb = Controller() def apply_keys(steer, throttle=1.0): # 先松开,再按需要的方向,避免同时按住两个方向键 for k in ("a", "d"): kb.release(k) if steer < -0.15: kb.press("a") elif steer > 0.15: kb.press("d") if throttle > 0.5: kb.press("w") else: kb.release("w")

死区设 0.15 是为了抑制小角度抖动,模型输出 0.1 和 -0.1 时都不按键,车走直线。这个死区不能设太大,超过 0.3 之后缓弯就转不过去了。

焦点管理是另一个隐形炸弹。注入按键之前必须确保游戏窗口在前台,否则按键会打到编辑器或者终端里,轻则没反应,重则误操作。常见做法是启动前用窗口句柄把目标窗口提到前台,并在循环里每几十帧检查一次当前前台窗口是否还是它,不是就暂停注入。

退出时的清理同样重要。用try/finally包住主循环,在finally里把所有按下的键release一遍。少了这一步,程序崩了之后方向键一直是按下状态,只能手动去按一下再松开才能恢复。

4.4 闭环评估:单圈时间与偏离率

离线 MSE 只能说明拟合得好不好,说明不了车开得好不好。真正要看的指标是闭环的:单圈时间、出界次数、平均偏离赛道中心线的距离。我一般跑 10 圈取中位数,因为第一圈往往受初始位置影响,最后一圈容易受偶发卡顿影响。

指标采集方式参考阈值
单圈时间从起跑线到再次越过起跑线与手动驾驶差距 15% 以内
出界次数车心偏离中心线超过半宽10 圈内不超过 3 次
平均横向偏差每帧计算并累加小于赛道宽度的 1/5
推理耗时单帧前向的毫秒数稳定在帧间隔的 40% 以下

单圈时间和手动成绩差 15% 以内,基本就算能用。差距在 30% 以上但不出界,通常是模型过于保守,转向幅度偏小,可以把训练集里大角度样本的权重再提一提。出界集中在某几个弯,把那几段的画面单独截出来看看,往往是训练时这几个弯过得少,样本不够。

5. 避坑与排查:五个让车撞墙的隐形问题

这一节列的都是我自己真实撞过的墙,现象、原因、解决按顺序写,遇到类似症状可以直接对号入座。

5.1 车一出直道就撞墙:直行样本占了七成

现象是训练损失降得很漂亮,验证集 MSE 也很低,但一上线车只会在直道上跑,进弯不减速也不打方向。原因是采集数据里接近零转向的帧占了绝大多数,模型找了个偷懒解:不管输入什么,输出都往 0 靠,平均损失就已经很低了。

解决办法是在训练前做分桶下采样,把直行桶的样本数压到中位数的两倍以内,同时给弯道样本更高的采样权重。改完之后验证损失可能会上升一点,但闭环表现会明显变好。判断是否处理到位,可以打印一下处理前后各个角度桶的样本数,直行桶占比从七成降到三成左右比较合适。

5.2 损失卡在 0.2 下不去:标签和输入都没归一化

现象是训练十几个 epoch,损失在 0.2 附近横盘,预测值几乎全是常数。原因通常有两个:标签没除以最大转角,输出层的梯度一直是几十的量级,Adam 的前几步直接把权重推到了饱和区;或者输入没除以 255,像素值在 0 到 255 之间,第一层的激活值极大,ReLU 大面积失活。

排查方法是训练前打印X.max()、X.mean()、y.min()、y.max()四个值。输入应该在 0 到 1 之间、均值 0.3 到 0.6,标签应该在 -1 到 1 之间。只要有一项越界,先修数据再谈调参。

5.3 离线误差很小,上线就画龙:训练与推理预处理不一致

现象是验证集 MSE 只有 0.002,上线后车却左右大幅摆动。最常见的原因是两份预处理代码不一致:训练时用面积插值,推理时用了默认的双线性;训练时裁的是窗口客户区,推理时裁的是整个窗口含边框;训练时除了 255,推理时忘了。

解决方式是只保留一份preprocess函数,训练和推理都从同一个模块导入,绝不复制粘贴。另外可以在推理启动时打印第一帧向量的均值、方差、最大值,和训练集统计量对一下,偏差超过 10% 就停下来查。

5.4 程序退出后按键卡住不放

现象是脚本 Ctrl+C 中断之后,游戏里的车一直往一个方向打满,得手动按一下反方向键才能恢复。原因是注入的按键在异常退出时没有被释放,系统层面还认为它是按下状态。

解决方式是把主循环包在try/finally里,finally中遍历所有可能按下的键逐个release。更稳妥的做法是注册信号处理,收到中断信号时先释放按键再退出。这类问题不难修,但每次调试都要手动恢复一次,很消耗耐心。

5.5 帧率越高车越傻:推理频率和游戏刷新不同步

现象是把推理循环的频率从 30 提到 60 之后,车反而更容易冲出赛道。原因是单帧前向耗时在 15 毫秒左右,60 帧的预算只有 16 毫秒,循环周期开始抖动,有时候一帧算完已经过了两帧游戏时间,模型基于旧画面做决策,动作总是慢半拍。

解决办法是把推理频率固定在一个有余量的值,比如 30 帧,每帧结束后用sleep补足剩余时间,让循环周期保持恒定。如果确实需要更高频率,先做模型瘦身:把第一层从 128 降到 64,或者把输入从 48×48 降到 40×40,单帧耗时能压到 8 毫秒以内再提频率。

6. 验证与进阶:用帧堆叠把「看不见的速度」补回来

单帧模型的天花板很明显:它不知道车有多快。同样一张弯道画面,低速时该继续给油,高速时该提前收油,但输入里没有这个信息。补这个短板最省事的办法是帧堆叠,把最近三帧拼成一个向量送进网络,让模型从画面的变化里自己推断速度。

方案输入维度参数量高速弯表现
单帧2304约 30 万容易切内线撞墙
三帧堆叠6912约 89 万明显改善
单帧 + 速度标量2305约 30 万改善最大,但需要读到速度

三帧堆叠的实现只需要在采集和推理两端各加一个滑动窗口,注意窗口必须同步保存,不能训练时堆三帧、推理时只喂一帧。

from collections import deque buf = deque(maxlen=3) def stacked(frame, p): buf.append(preprocess(frame)) while len(buf) < 3: buf.appendleft(buf[0]) # 启动时用首帧补齐,保证维度恒定 x = np.concatenate(list(buf))[:, None] # (6912, 1) out, _ = forward(p, x) return float(out[0, 0])

appendleft补首帧这一步不能省,否则程序刚启动的前两帧维度是 2304 和 4608,矩阵乘法会直接抛形状错误。如果游戏本身能读到车速,更划算的做法是把速度归一化后作为一个标量拼在输入末尾,维度只多 1,效果比堆三帧还好,代价是要多写一段读取内存或调用接口的代码。

验证改进是否有效,不要只看离线损失,因为堆叠之后输入维度变了,MSE 不可比。固定跑 10 圈,记录单圈时间和出界次数,和单帧版本对照。我的经验是出界次数能降一半以上,单圈时间缩短 8% 到 12%,但推理耗时增加约 40%,帧率紧张的话要重新算预算。

还有一个容易被忽略的验证手段:把模型在训练集上的预测角度和真实角度画成两条曲线,看相位差。如果预测曲线整体右移几帧,说明预处理里有额外延迟;如果只在弯心处偏差大,说明训练样本在这个角度区间不够。这条曲线比任何一个标量指标都直观,我现在每次训完新模型都会先画一遍。

从那以后我每次改预处理或者换输入尺寸,都会先把同一段录像回放一遍,让训练脚本和推理脚本各跑一次,逐帧比对两边的向量是不是完全一致,再谈训练。这个习惯帮我省掉了至少三次通宵排查。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询