ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

用Python+NumPy从零实现赛车神经网络自动驾驶

用Python+NumPy从零实现赛车神经网络自动驾驶

简介:面向神经网络入门者与 Python 游戏 AI 爱好者的一套实战项目包,围绕用神经网络操控赛车游戏展开,既包含基于 TensorFlow 框架的实现,也提供自行搭建 DNN 的对照版本,可用于理解数据生成、模型训练、推理决策与游戏环境交互的完整链路,适合课程设计、自学实验和同类小项目参考。压缩包共 54 个文件,约 8.9MB,以 py 源码、pyc 缓存、txt 说明、xml 配置及 png 贴图、wmv 演示视频为主,并附有模型 checkpoint、data、meta、index 等训练产物,便于直接运行、复现实验与查看效果。已有 2064 人学习下载。读者可从中获得赛车游戏逻辑、训练数据生成、模型训练与 AI 测试等模块的组织思路,也能参考人机比赛与人工试玩录屏,结合地图设计和车辆素材理解项目结构,并对照两套实现体会框架调用与手写网络在训练流程上的差异,适合作为神经网络控制类小项目的入门案例与二次开发基础。

1. 规则写到第七版我放弃了,改用 Python 自己实现神经网络

赛车游戏里最劝退的不是画面,是让车看起来会开。最早我用 if 判断前方几条射线距离,小于阈值就转向,直道能跑,一进 S 弯就贴墙,规则改到第七版已经没人看得懂哪一行在干活。后来换路子:用 Python 自己实现神经网络,射线距离当输入,转向和油门当输出,人先开几十圈采轨迹做监督训练,权重存文件,游戏启动直接加载接管。适合两类人:想亲手把前向传播、损失、反向传播推一遍而不是调库的;课程设计或小 Demo 需要一个能演示、能改参数、报错能定位到具体层的端到端案例的。NumPy 加 pygame 就够,不用深度学习框架。

2. 先把环境搭稳:赛道折线、七条射线与 9 维状态向量

2.1 自己搭环境的三个理由:确定性、可暂停、能标注

不少人的第一反应是拿现成赛车游戏,写内存读取或者截屏识别。这两条路我都试过,翻车点很一致:截屏方案帧率不稳,同一帧在不同机器上像素亮度都不一样,模型学出来的是光照和画质,不是赛道几何;内存读取更脆,游戏一更新偏移全变,前面采的几十万条样本直接作废。自己用 pygame 搭一个,成本不到两百行,换来的是完全确定性——同一个随机种子、同一串按键,跑出来是同一条轨迹。调神经网络最怕“这次和上次不一样”,确定性是唯一的后悔药。

物理步长必须和渲染帧率解耦。我一般把物理固定成 1/60 秒一步,渲染跟着走,机器卡了就用累加器补步,而不是把 dt 放大。dt 一变,同样的按键序列在不同机器上跑出的轨迹就不同,采到的数据就带上了硬件噪声。这个问题在采集阶段几乎看不出来,等训练完上车乱撞才想起来,只能全部重采。环境主循环的骨架大致是这样:

import pygame, numpy as np FPS, DT = 60, 1.0 / 60 MAX_DIST = 400.0 # 射线最大探测距离,像素 RAY_ANGLES = np.deg2rad([-90, -60, -30, 0, 30, 60, 90]) def run(env, policy, max_frames=10000): acc = 0.0 clock = pygame.time.Clock() for _ in range(max_frames): acc += clock.tick(FPS) / 1000.0 state = env.get_state() steer, throttle = policy(state) while acc >= DT: # 固定步长推进物理 env.step(steer, throttle, DT) acc -= DT env.render()

policy在采集阶段返回键盘输入,在推理阶段返回网络输出,环境本身不需要知道是谁在开车,同一套物理逻辑复用到底。acc是时间累加器,卡顿时会连续补几步物理,保证轨迹和帧率无关。MAX_DIST和RAY_ANGLES放进配置文件,后面换赛道只改这两个常量。

2.2 赛道用两条折线表示,射线求交只算叉积

赛道不用图片,用左右两条边界折线,每条由若干线段首尾相接。好处是射线求交是纯几何运算,没有像素采样误差,改赛道形状只要改顶点数组,还能顺手做“训练用赛道 A、验证用赛道 B”的泛化测试。求交用参数方程:射线是 P + t·d,线段是 A + u·e,联立之后 t 和 u 都能用二维叉积解出来,u 落在 [0,1] 才算真的打在线段上,t 取最小正值。

def ray_segment(origin, direction, a, b, max_dist): """origin/direction/a/b 都是 (2,) 的 float 数组 返回射线沿 direction 打到线段 ab 的距离,未命中返回 max_dist""" e = b - a denom = direction[0] * e[1] - direction[1] * e[0] if abs(denom) < 1e-9: # 平行,判未命中 return max_dist diff = a - origin t = (diff[0] * e[1] - diff[1] * e[0]) / denom # 射线参数 u = (diff[0] * direction[1] - diff[1] * direction[0]) / denom # 线段参数 if t < 0.0 or u < 0.0 or u > 1.0: return max_dist return min(t, max_dist)

denom趋近 0 说明射线和墙平行,这时不该抛异常,直接当没打中处理,因为赛车贴墙行驶时平行是常态。t < 0表示墙在车屁股后面,必须丢掉,不丢的话车头后面的墙会污染前方读数。u越界说明命中的是线段的延长线而不是线段本身,这是折线赛道最容易出错的地方,漏掉这个判断,车在弯道口会看到一条“幽灵墙”。max_dist建议取赛道宽度的 1.5 倍左右,太大远处墙面的距离变化很迟钝,太小直道上永远读到满值,网络分不清“前面很远”和“没有墙”。

七条射线要按同一套矩阵旋转,别一条条手写角度:

def cast_rays(pos, heading, walls, max_dist=MAX_DIST): base = np.array([np.cos(heading), np.sin(heading)]) out = [] for ang in RAY_ANGLES: c, s = np.cos(ang), np.sin(ang) d = np.array([c * base[0] - s * base[1], s * base[0] + c * base[1]]) dist = min(ray_segment(pos, d, a, b, max_dist) for a, b in walls) out.append(dist / max_dist) return np.array(out, dtype=np.float32)

2.3 状态向量定成 9 维,多一维都是噪声

状态就用 9 个数:7 条射线归一化距离、归一化速度、上一帧的转向量。射线角度从 -90 到 +90 每 30 度一条,覆盖车头半圆,左右对称布置,网络不用额外学镜像关系。射线全部除以 max_dist 落到 [0,1],速度除以最大速度,转向本身就在 [-1,1],输入尺度一致之后,手写梯度下降时不容易被某一维主导。

维度含义取值范围备注
0-6七条射线归一化距离0 ~ 1角度 -90,-60,-30,0,30,60,90
7归一化速度0 ~ 1当前速度 / 最大速度
8上一帧转向-1 ~ 1给网络一点惯性信息

第 8 维最容易被省掉,省掉之后直道上方向盘会高频左右抽,因为连续几帧输入几乎相同,输出的微小差异被放大成抖动。把上一帧转向喂进去,相当于给网络一个隐式的低通滤波,代价只是推理时多存一个变量。还有个坑是“速度要不要归一化到最大速度”,如果赛道上有加速带导致速度超上限,归一化值会大于 1,训练时没见过,推理时就会出现异常输出,所以物理层要做硬截断。

2.4 动作空间:转向连续、油门分两档

动作两维,转向连续值,键盘左右键相减得到 -1、0、1;油门只给两档,按住加速是 1.0,松开是 0.4,不给 0。原因是完全松油门在弯道里等于放弃治疗,采出来的数据会混进大量接近停车的样本,网络学到的“最优策略”就是慢慢蹭,跑出来的圈速没法看。如果确实需要刹车,宁可再加一档 0.0,也不要让它成为默认。

采集代码本身很短,关键是每一帧先取状态再执行动作,顺序反了标签就对不上:

def collect(env, frames=6000, out_path="lap_demo_01.npz"): states, actions = [], [] for _ in range(frames): keys = pygame.key.get_pressed() steer = float(keys[pygame.K_RIGHT]) - float(keys[pygame.K_LEFT]) throttle = 1.0 if keys[pygame.K_UP] else 0.4 states.append(env.get_state()) # 9 维,已归一化 actions.append([steer, throttle]) env.step(steer, throttle, DT) env.render() np.savez_compressed(out_path, s=np.asarray(states, dtype=np.float32), a=np.asarray(actions, dtype=np.float32))

frames按 60 帧每秒,6000 帧大约 100 秒,正常能跑三四圈。out_path带赛道名和序号,后面做数据混合时一眼看出是哪一批。存 npz 不存 csv,float32 体积小一半,加载几十万条不到一秒。每采完一批立刻回放检查有没有 NaN,pygame 里除零是静默产生 nan 的,混进训练集之后损失会直接变 nan,那种时候你翻遍网络结构也找不到原因。

3. 手写三层网络:前向、损失与反向传播怎么对齐

3.1 结构选 9-32-32-2,理由不是“够用”而是“可推”

输入 9 维,两个隐藏层各 32 个单元,输出 2 维。隐藏层用 ReLU,输出层用 tanh。选 32 不是拍脑袋,是权衡的结果:9 维输入本身信息量不大,隐藏单元给到 128 以上在几百圈的数据量下几乎必然过拟合,训练损失能到 0.001,验证集一塌糊涂;给到 8 个又学不会弯道和直道的区分。32 个单元大概能容纳“左前距离、正前距离、右前距离”这几组特征的组合,同时又小到可以打印权重矩阵肉眼看。

输出层用 tanh 而不是 softmax,因为转向是连续量。用 softmax 做九分类(左满、左中、左轻、直行……)看起来训练更稳,实际上量化台阶会让车在弯道里一跳一跳,而且分类边界附近的样本永远学不准。油门同理,输出 tanh 之后线性映射到 [0.4, 1.0],比直接 sigmoid 更好调,因为 sigmoid 在 0 附近梯度大、饱和区梯度小,油门这个维度本来就该是“要么踩要么松”,不需要那么细的分辨率。

3.2 前向传播:把中间结果缓存下来给反向用

前向没什么花活,关键是每一层的输入都要存进 cache,反向传播时直接用,不然就得重算一遍,训练速度直接腰斩。

import numpy as np class Net: def __init__(self, sizes=(9, 32, 32, 2), seed=0): rng = np.random.default_rng(seed) self.W = [rng.normal(0, np.sqrt(2.0 / n), (n, m)) for n, m in zip(sizes[:-1], sizes[1:])] # He 初始化 self.b = [np.zeros(m) for m in sizes[1:]] self.cache = [] def forward(self, x): h = x self.cache = [x] for i, (W, b) in enumerate(zip(self.W, self.b)): z = h @ W + b h = np.maximum(0.0, z) if i < len(self.W) - 1 else np.tanh(z) self.cache.append(h) return h

初始化用 He 而不是全零或小随机数。全零会让同一层所有神经元梯度完全一致,训练完等于只有一个神经元干活;标准差取 1e-2 这种小值,配合 ReLU 会让第二层之后的激活值迅速塌到 0,梯度传不回去,损失卡在 0.25 附近不动,看起来像学不会,其实是初始化把它按死了。sqrt(2/n)里的 2 是给 ReLU 补的方差,换成 tanh 隐藏层要改成sqrt(1/n)。

3.3 损失函数:转向用加权 MSE,油门单独算

两个输出维度量纲不同,损失要分开算。转向用均方误差,但对大转向样本加权,因为直道样本天然占七成以上,不加权的话网络会倾向于输出 0,也就是直行,遇到弯道直接推头撞墙。油门用均方误差就够,它只有两档,不需要交叉熵那套。

def loss_and_grad(net, x, y, w_steer=1.0): pred = net.forward(x) # (B, 2) err = pred - y # 转向维按 |标签| 加权:弯道样本权重高,直道权重 1 steer_w = 1.0 + w_steer * np.abs(y[:, :1]) d_steer = 2.0 * err[:, :1] * steer_w / x.shape[0] d_thr = 2.0 * err[:, 1:] / x.shape[0] dout = np.concatenate([d_steer, d_thr], axis=1) return float(np.mean(err ** 2)), dout

steer_w让转向标签为 ±1 的样本梯度放大两倍,直道样本保持不变,相当于在损失层面做了一次软重采样。/ x.shape[0]是批平均,忘了除的话学习率要相应缩小 batch_size 倍,换批大小就得重调,血泪经验。w_steer默认 1.0,赛道弯多调到 2.0,弯少调回 0.5,这个参数比学习率更影响最终圈速。

3.4 反向传播:四行代码,但顺序不能错

反向就是链式法则倒着走。输出层是 tanh,局部梯度是 1 - a²;隐藏层是 ReLU,局部梯度是 z > 0;然后乘上游梯度得到 dz,再算 dW = a_prev.T @ dz、db = dz.sum(axis=0),最后把梯度往上一层传是 dz @ W.T。

def backward(self, dout): gW = [None] * len(self.W) gb = [None] * len(self.b) for i in range(len(self.W) - 1, -1, -1): if i == len(self.W) - 1: dz = dout * (1.0 - self.cache[i + 1] ** 2) # tanh 导数 else: dz = dout * (self.cache[i + 1] > 0.0) # ReLU 导数 gW[i] = self.cache[i].T @ dz gb[i] = dz.sum(axis=0) dout = dz @ self.W[i].T # 传给下一层 return gW, gb

最容易错的是dout = dz @ self.W[i].T这一行放的位置。如果放在循环开头,第一层拿到的上游梯度就是错的;如果漏掉,只有最后一层有梯度,前面两层永远是初始值,现象是损失下降一点点就彻底不动。另外self.cache[i + 1] > 0用的是本层激活前的 z 还是激活后的 a,取决于你 cache 里存了什么,这里存的是激活后的输出,所以隐藏层用cache[i+1] > 0判断 ReLU 是否激活,和存 z 的写法刚好相反,混用会让梯度检验误差在 1e-2 量级,看着像数值误差,其实是逻辑错了。

3.5 梯度检验:手推完必须验一次

写完反向不要直接开训,先跑数值梯度检验。中心差分对每个参数扰动 ±eps,算损失变化,和解析梯度比相对误差,正常应该在 1e-7 量级,超过 1e-4 就是有 bug。

def grad_check(net, x, y, eps=1e-5): _, dout = loss_and_grad(net, x, y) gW, _ = net.backward(dout) for li in range(len(net.W)): for _ in range(5): # 每层随机抽 5 个位置 i = np.random.randint(net.W[li].shape[0]) j = np.random.randint(net.W[li].shape[1]) old = net.W[li][i, j] net.W[li][i, j] = old + eps lp, _ = loss_and_grad(net, x, y) net.W[li][i, j] = old - eps lm, _ = loss_and_grad(net, x, y) net.W[li][i, j] = old num = (lp - lm) / (2 * eps) rel = abs(num - gW[li][i, j]) / max(1e-8, abs(num) + abs(gW[li][i, j])) print(f"layer{li} [{i},{j}] rel_err={rel:.2e}")

这段代码用很小的批(比如 8 条)跑,因为每个参数要前向两次,全量数据会慢到不想等。检验通过之后把这段注释掉,别留在训练循环里,否则每个 epoch 多花几十秒,很容易让人误以为训练本身慢。

4. 训练循环里真正决定成败的参数

4.1 先解决样本分布,再谈学习率

训练不收敛的时候,九成的人第一反应是调学习率,其实先看样本分布。人开的时候直道下意识踩满油门,弯道减速,采出来的数据里直道样本占比经常超过 70%,网络只要学会“输出接近 0 的转向”就能把平均损失压得很低,但上车遇到弯道就直愣愣撞过去。解决办法有两个:一是采集时刻意多跑弯道,同一条弯反复切进去;二是训练时按 |steer| 分桶重采样。

def make_sampler(actions, bins=(0.0, 0.3, 0.7, 1.01)): steer_abs = np.abs(actions[:, 0]) bucket = np.digitize(steer_abs, bins) - 1 counts = np.bincount(bucket, minlength=3).astype(np.float64) w = 1.0 / np.maximum(counts, 1) # 桶内样本越少权重越高 return w[bucket] / w[bucket].sum()

bins把转向绝对值分成三段:接近直行、中等转向、满舵。w是每个样本被抽到的概率,反比于它所在桶的样本数,这样满舵样本虽然少,但每个 epoch 被看到的次数和小样本桶拉平。采样权重是“有放回”抽样,np.random.choice的p参数直接吃这个数组,一个 epoch 的样本数和原始数据量保持一致,不会因为过采样把小桶复制几十遍导致过拟合。

4.2 优化器手写 Adam,别用裸 SGD 硬扛

小网络上 SGD 也能收敛,但学习率极其敏感,1e-2 会炸、1e-3 又慢,调参调到怀疑人生。Adam 对学习率不敏感,1e-3 基本能应付大部分情况,实现也就二十行。

class Adam: def __init__(self, net, lr=1e-3, b1=0.9, b2=0.999, eps=1e-8): self.net, self.lr, self.b1, self.b2, self.eps = net, lr, b1, b2, eps self.mW = [np.zeros_like(w) for w in net.W] self.vW = [np.zeros_like(w) for w in net.W] self.mb = [np.zeros_like(b) for b in net.b] self.vb = [np.zeros_like(b) for b in net.b] self.t = 0 def step(self, gW, gb): self.t += 1 for i in range(len(self.net.W)): self.mW[i] = self.b1 * self.mW[i] + (1 - self.b1) * gW[i] self.vW[i] = self.b2 * self.vW[i] + (1 - self.b2) * gW[i] ** 2 mhat = self.mW[i] / (1 - self.b1 ** self.t) vhat = self.vW[i] / (1 - self.b2 ** self.t) self.net.W[i] -= self.lr * mhat / (np.sqrt(vhat) + self.eps) # b 的更新同理,代码略

b1、b2控制一阶和二阶矩的平滑程度,默认 0.9 / 0.999 在小批量下很稳。self.t是偏差修正项,不做修正的话前几十步的动量估计偏小,等效学习率被压得很低,看起来像“训练前 20 个 epoch 完全没动静”。eps防止除零,1e-8 就够,调到 1e-4 会让更新量整体变小,效果和学习率除以十差不多。

4.3 批大小 64、学习率 1e-3、epoch 看验证损失

批大小我固定 64。太小(8、16)梯度噪声大,损失曲线抖得看不出趋势;太大(512)在几万条样本的数据量下每个 epoch 只有几十次更新,收敛慢而且容易卡在局部极小。学习率配 Adam 用 1e-3,训练损失在前 10 个 epoch 应该从 0.2 降到 0.02 量级,如果 10 个 epoch 之后还在 0.15 以上,先查数据,再查梯度。

参数取值调整方向现象
batch_size64数据量小于 2 万时降到 32损失曲线抖动大
lr1e-3损失 nan 降到 3e-4前几步就发散
epochs60~120看验证损失早停验证损失回升
w_steer1.0~2.0弯道多往上调弯道推头
隐藏单元32过拟合降到 16训练降验证升

训练循环本身很简单,关键是把训练集和验证集按“圈”划分,而不是按帧随机划分。按帧划分会让相邻帧同时出现在训练和验证里,验证损失虚低,看起来泛化很好,换条赛道立刻原形毕露。

def train(net, data, val, epochs=80, bs=64, lr=1e-3): opt = Adam(net, lr=lr) p = make_sampler(data["a"]) best = float("inf") for ep in range(epochs): idx = np.random.choice(len(data["s"]), size=len(data["s"]), p=p) for k in range(0, len(idx), bs): batch = idx[k:k + bs] _, dout = loss_and_grad(net, data["s"][batch], data["a"][batch]) gW, gb = net.backward(dout) opt.step(gW, gb) vl, _ = loss_and_grad(net, val["s"], val["a"]) if vl < best: best = vl np.savez("best_weights.npz", W=net.W, b=net.b) print(f"ep{ep:03d} val={vl:.4f} best={best:.4f}")

验证损失连续 15 个 epoch 不降就停,别硬跑。权重只在验证损失刷新时落盘,训练中途断开也不会丢失最好的一版,这个习惯能省掉很多重复训练。

5. 排查手册:损失降了车却撞墙的六个现场

5.1 训练阶段的四个翻车点

现象:训练损失 0.01,上车直道就撞墙。原因几乎一定是样本分布,直道样本占比过高,模型学到的策略是“永远输出接近 0 的转向”,在直道上表现完美所以损失很低。解决:按 |steer| 分桶重采样,或者重新采集时刻意多跑弯道;同时在损失里给大转向样本加权,先看训练集里 |steer| > 0.7 的样本占比有没有到 15%,不到就补数据。

现象:损失从 0.2 直接跳到 nan。原因通常是学习率过大或者输入里有 nan。先检查采集数据有没有 nan(np.isnan(s).any()),再检查学习率,Adam 配 1e-2 在前几步很容易炸。解决:学习率降到 3e-4 重跑,同时在训练循环里加一句dout = np.clip(dout, -1.0, 1.0)做梯度裁剪,防止某个异常样本把整批梯度带飞。

现象:验证损失降,训练损失不降。这种反直觉的情况一般是数据泄漏,验证集和训练集来自同一批连续帧,模型在训练时见过几乎相同的输入。解决:按圈划分数据集,验证集用完全没参与训练的那几圈,并且换一条形状不同的赛道做第二次验证,两个都过才算真的收敛。

现象:损失降到 0.05 之后完全不动。常见原因是 ReLU 死了,隐藏层大量神经元输出恒为 0,梯度传不回去。解决:把初始化换成 He,学习率降一个数量级重训,或者把隐藏层激活换成 LeakyReLU(负半轴斜率 0.01)。判断方法很简单,打印第二层激活值的均值,如果接近 0 且方差极小,就是死了。

5.2 推理与部署阶段的坑

现象:训练时好好的,接管之后车原地抽搐。原因是推理时的输入和训练时的输入不一致,最常见的三种:射线角度顺序反了、忘了除以 max_dist、速度归一化的分母和训练时不同。解决:把get_state抽成一个函数,训练和推理共用,别在推理端重写一遍。我习惯在接管前先跑一遍“影子模式”,网络输出不接到车上,只把网络输入和训练集里最近的样本比一下,距离过大就说明预处理有偏差。

现象:车能跑但一直在车道里画龙。这是高频抖动,原因有两个:一是状态里没有上一帧转向,网络不知道自己做过的动作;二是推理输出没做平滑。解决:状态补上第 8 维,同时在推理端加一阶低通,steer = 0.7 * last_steer + 0.3 * net_out,系数 0.7 是我在 60 帧下试出来的,赛道窄调到 0.85,赛道宽可以放到 0.5。平滑本身会引入延迟,弯道急的赛道别调太狠,否则入弯晚半拍。

现象:同一份权重,换了台机器就撞墙。大概率是推理频率不同导致的物理步长不一致,或者浮点精度差异被网络放大了。解决:推理循环里也用固定步长累加器,别直接用clock.tick的返回值当 dt;权重统一存 float32,加载后打印一下每层权重的均值和方差,和训练结束时对比,数值对不上就是加载错了文件,这种低级错误在文件多了之后非常常见。

6. 从能跑到跑得稳:接管流程、验证指标与提速

接管这一步不要直接切,分三段走。第一段影子模式跑一圈,只记录网络输出和人工操作的偏差,差值中位数超过 0.2 就回去补数据;第二段限速接管,物理层把最大速度压到七成,看能不能完整跑完两圈;第三段才放开全速。这个流程看着啰嗦,但能让你在车还没撞墙的时候就知道哪一层出了问题。

推理输出务必平滑,代码就三行:

last = 0.0 def policy(state, net, alpha=0.7): global last out = net.forward(state[None, :])[0] steer = float(out[0]) steer = alpha * last + (1 - alpha) * steer # 一阶低通 last = steer throttle = 0.4 + 0.6 * (float(out[1]) + 1.0) / 2.0 return np.clip(steer, -1.0, 1.0), throttle

alpha越大越稳但越迟钝,0.7 是个不错的起点。throttle把 tanh 的 [-1,1] 线性映射回 [0.4,1.0],和训练时的标签映射必须完全一致,映射区间写错是接管失败的高频原因。

验证别只看损失,看三个指标:

指标计算方式合格线
完赛率10 次随机起点跑完圈数 / 10≥ 8
平均横向偏差车身到中心线距离的均值< 赛道宽度 15%
单圈时间三次取中位数接近人工记录的 1.3 倍内

指标不达标时优先看第二个,横向偏差大说明状态编码不够或者平滑过头,单圈时间慢则多半是油门维学得太保守。

提速方面,实时单帧推理根本用不上批量,真正的瓶颈是每帧七次射线求交加一次前向,Python 里大概 1~2 毫秒,完全够 60 帧。要提速优先减射线数,七条降到五条(去掉最外侧两条)圈速掉得不多但帧率稳很多;其次是把权重从 npz 加载后转成连续内存的 float32 数组,避免每次前向都做类型转换。

我自己的习惯是:每次改完环境或者状态编码,先用固定种子跑一遍回放,把状态序列和上一版逐帧对比,确认只有预期内的差异,再开始训练。吃过一次亏,改了射线角度忘了改配置里的归一化常量,训练损失漂亮得不像话,上车三十秒撞了七次,回头查了两个小时才发现是配置文件不同步。从那以后每次动环境都强制走一遍这个对比流程,希望帮到你。

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

返回列表