numpy手写神经网络赛车:从监督学习到进化策略

发布时间:2026/10/24 8:37:07
numpy手写神经网络赛车:从监督学习到进化策略

简介:这份压缩包围绕“用 Python 自己实现神经网络操纵赛车游戏”展开,面向已具备 Python 基础、希望从零理解神经网络训练与游戏 AI 控制流程的入门学习者。内容把游戏逻辑、赛道与车辆素材、TensorFlow 版训练脚本、纯手写 DNN 实现、样本数据与模型检查点组织在一起,便于对照两种实现路径理解网络结构、激活函数、数据采集和模型测试,也可作为课程设计或小型项目参考。压缩包共 54 个文件,以 16 个 py 脚本为主,辅以 pyc、txt、xml、png 素材及模型数据、checkpoint 等文件,整体约 8.9MB,目录按游戏逻辑、两种 AI 实现和数据模型分层,便于逐模块阅读。已有 2064 人学习,配套的人机比赛与人工操作录屏、地图和车辆图像等,能帮助读者把训练、测试和实际运行效果串联起来,并观察神经网络在游戏场景中的决策表现。

1. 从一圈都跑不完说起:网络到底该看什么

搜「神经网络操纵赛车游戏」的人,脑子里多半先有一段画面:车自己贴弯、出弯、加油。真动手时卡住的第一个问题不是网络多深,而是网络到底看什么。我最初把整张 400×300 画面塞进去,一万多帧数据、训了两天,车还是往墙上怼。后来换成五根从车头射出去的测距射线加一个速度值,六维输入、三层网络、三千帧人工示教,笔记本 CPU 上几十秒一轮,半小时能跑完一圈。

这条路子适合想搞明白「自己实现神经网络」到底怎么回事的人:不依赖深度学习框架,numpy 写完前向和反向,一百多行;不依赖显卡;出问题能一层层拆开看,而不是对着黑匣子猜。下面从赛道怎么定义讲起,一路走到训练、评估和排错。

2. 赛道与传感器:搭一个策略能随时接管的环境

环境是后面所有工作的地基。地基没打好,网络结构再花哨也是白搭:传感器抖动、物理步长不稳、碰撞判定含糊,这三样任何一个出问题,训练曲线都会变成玄学。我的做法是把赛道建成「中心线折线 + 固定宽度」,这样碰撞检测和射线测距可以共用同一套几何数据,不用维护两套逻辑。

2.1 中心线加宽度:碰撞和测距一次算清

闭合赛道用极坐标生成,半径随角度做几次谐波叠加,弯道曲率就自然有了变化,改参数就能出不同难度的图。中心线采样成折线后,向法线方向偏移半个路宽,得到左右两条边界折线。

import numpy as np def make_track(n=360, r0=420.0, a1=60.0, a2=30.0, width=90.0): """生成闭合赛道:中心线折线 + 左右边界折线""" t = np.linspace(0.0, 2.0 * np.pi, n, endpoint=False) r = r0 + a1 * np.sin(3.0 * t) + a2 * np.sin(5.0 * t) center = np.stack([r * np.cos(t), r * np.sin(t)], axis=1) # 用前后相邻点差分求切线,比单边差分在闭合处更稳 tangent = np.roll(center, -1, axis=0) - np.roll(center, 1, axis=0) tangent /= np.linalg.norm(tangent, axis=1, keepdims=True) normal = np.stack([-tangent[:, 1], tangent[:, 0]], axis=1) left = center + normal * (width / 2.0) right = center - normal * (width / 2.0) return {"center": center, "left": left, "right": right, "tangent": tangent, "width": width}

n=360是分辨率,周长两千多像素时每段约七像素,射线求交的误差落在可接受范围内;再往下调,弯道内侧会出现肉眼可见的折角。r0是基础半径,决定整张图多大;a1、a2分别是三次和五次谐波振幅,调大就是连续 S 弯,调小接近椭圆,训练集和测试集的差异基本靠这两个参数拉开。width是路宽,九十像素对车长二十来像素的车来说约等于四个车身,新手示教时不至于一步撞墙。

射线求交用线段参数方程,一次性对所有边界段做向量化计算,避免 Python 里写双层循环:

def ray_cast(origin, direction, polyline): """返回射线与折线最近交点的距离,没有交点返回 inf""" p1 = polyline p2 = np.roll(polyline, -1, axis=0) v2 = p2 - p1 # 边界段方向 v1 = origin - p1 # 起点指向线段起点 det = v2[:, 0] * direction[1] - v2[:, 1] * direction[0] ok = np.abs(det) > 1e-9 # 平行段直接排除 safe = np.where(ok, det, 1.0) t = (v1[:, 0] * v2[:, 1] - v1[:, 1] * v2[:, 0]) / safe # 射线参数 u = (v1[:, 0] * direction[1] - v1[:, 1] * direction[0]) / safe # 线段参数 valid = ok & (t > 0.0) & (u >= 0.0) & (u <= 1.0) return float(t[valid].min()) if valid.any() else np.inf

t > 0保证交点在射线正方向上,0 <= u <= 1保证交点落在线段内而不是延长线上。这两个条件缺一个,就会出现在赛道外侧仍然读到「很近的墙」的怪象,训练出来的车会莫名其妙地朝外打方向。平行判断阈值取1e-9是针对像素量级调过的,赛道尺度放大十倍后要同步调小。

2.2 车辆运动学:三个状态量就够,积分方式别乱写

车用简化自行车模型,状态只有位置(x, y)、朝向theta和速度v。转向用三个离散动作表示,油门固定踩死,速度由阻力项自然收敛到上限。之所以不在第一版里学油门,是因为油门和转向同时学,标签空间变成九种组合,三千帧数据根本喂不饱,而「保持速度、只管方向」这件事本身已经足够让车跑完一圈。

class Car: def __init__(self, x, y, theta): self.x, self.y, self.theta = x, y, theta self.v = 0.0 self.wheelbase = 24.0 # 轴距,影响转弯半径 self.max_speed = 220.0 self.accel = 200.0 self.drag = 1.1 # 阻力系数,稳态速度 = accel / drag self.max_steer = 0.55 # 最大前轮转角(弧度) def step(self, action, dt): # action: 0=左, 1=直行, 2=右 delta = (-self.max_steer, 0.0, self.max_steer)[action] # 速度先更新,再用新速度推进位置,避免高速下轨迹滞后一帧 self.v += (self.accel - self.drag * self.v) * dt self.v = min(self.v, self.max_speed) # 朝向变化率 = v / L * tan(delta),低速时不会瞬移 self.theta += self.v / self.wheelbase * np.tan(delta) * dt # 中点法:用更新后朝向的半步位移,抑制大 dt 下的漂移 self.x += self.v * np.cos(self.theta) * dt self.y += self.v * np.sin(self.theta) * dt

wheelbase越小转弯越贼,但太小会让车在直道上轻微画龙;二十四像素是反复试出来的平衡点。max_steer决定最小转弯半径,赛道最急的弯如果过不去,先调这个参数而不是怪网络。速度更新放在位置更新之前,是因为先算速度再算位移更接近连续时间的积分顺序,反过来的话,一帧内的加速度不会体现在位移上,高速时表现为入弯偏外。

出界判定用点到中心线折线的最近距离,超过半个路宽减车半径就算碰撞。这一步不要用矩形包围盒近似,斜着切弯时误差能到十几个像素,会让训练数据里混进大量「擦边不算撞」的样本。

2.3 把控制抽成 Policy 接口,键盘和网络共用一套循环

环境写完后,最忌讳的是把「读键盘」和「跑游戏」焊死在一起。我第一版就是直接在事件循环里读键,结果录完数据想换成网络跑,得把主循环复制一份改,两份代码很快就不同步了。正确做法是抽一个策略接口,键盘和神经网络都实现同一个act方法。

import pygame class Policy: def act(self, obs): raise NotImplementedError class KeyboardPolicy(Policy): """人工示教:方向键控制,空格重置""" def act(self, obs): keys = pygame.key.get_pressed() if keys[pygame.K_LEFT]: return 0 if keys[pygame.K_RIGHT]: return 2 return 1 class NetPolicy(Policy): """把观测送进网络,取概率最大的动作""" def __init__(self, net): self.net = net def act(self, obs): prob = self.net.forward(obs[None, :]) return int(np.argmax(prob[0]))

观测向量固定六维:五根射线的归一化距离,角度偏移分别是 -60、-30、0、30、60 度;第六维是当前速度除以max_speed。射线打到的最远距离截断在 240 像素,超过就按 1.0 计,这样网络看到的是「前方开阔」而不是一个越来越大的数。五根射线是最低配置,能覆盖正前方和两侧各三十度,再少一根就分不清「贴左墙」和「贴右墙」。

主循环统一成step(policy, dt, render)一个函数,渲染与否只影响画不画图,不影响任何物理量。这一点在后面排查「渲染模式正常、无渲染模式撞墙」那个坑时是关键。

def run_episode(env, policy, max_steps=4000, dt=1.0 / 60.0, render=False): obs = env.reset() traj = [] for _ in range(max_steps): action = policy.act(obs) traj.append((obs.copy(), action)) obs, done = env.step(action, dt) if render: env.draw() pygame.event.pump() if done: break return traj, env.lap_progress()

dt固定 1/60,不取clock.tick()的返回值。这一步看起来反直觉,但正是稳定性的来源:录制和回放必须用同一个物理步长,否则同一串动作在两种帧率下会跑出两条轨迹。渲染时的等待单独用clock.tick(60)控制,只影响显示节奏。

3. 手写三层网络:前向、反向与损失函数

网络部分我坚持用 numpy 手写,不是为了炫技,是因为这个任务的规模小到不需要框架:输入六维、隐藏十六、输出三类,参数量不到两百个,写一遍前向反向比查框架文档还快,而且每一步梯度长什么样都能直接打印出来。

3.1 定维度:输入输出先钉死,中间层再说

输入六维在上一章已经确定。输出三类,对应左、直、右,用 softmax 转成概率,训练时用交叉熵。隐藏层选十六个神经元,理由是六维输入做三分类,边界大致是输入空间里的几个超平面组合,十六个 ReLU 足够表达;加到六十四会明显过拟合,因为训练样本只有三千帧左右,且高度相关(相邻帧几乎一样)。

激活函数用 ReLU 而不是 sigmoid,原因很实际:sigmoid 在输入稍微大一点时就饱和,梯度接近零,而这个任务的输入里速度那一维归一化后经常在 0.8 以上,第一层很容易被压死。

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

class Net: def __init__(self, n_in=6, n_hidden=16, n_out=3, seed=0): rng = np.random.default_rng(seed) # He 初始化:适配 ReLU,方差取 2/n_in self.W1 = rng.normal(0.0, np.sqrt(2.0 / n_in), (n_in, n_hidden)) self.b1 = np.zeros(n_hidden) self.W2 = rng.normal(0.0, np.sqrt(2.0 / n_hidden), (n_hidden, n_out)) self.b2 = np.zeros(n_out) self.keys = ["W1", "b1", "W2", "b2"] def forward(self, X, cache=None): z1 = X @ self.W1 + self.b1 a1 = np.maximum(z1, 0.0) # ReLU z2 = a1 @ self.W2 + self.b2 z2 = z2 - z2.max(axis=1, keepdims=True) # softmax 前减最大值,防溢出 e = np.exp(z2) p = e / e.sum(axis=1, keepdims=True) if cache is not None: cache.update({"X": X, "z1": z1, "a1": a1, "p": p}) return p

减最大值那一步是血泪经验。不加的话,某一帧观测里如果混进一个没归一化的异常值,exp直接返回inf,inf / inf得到nan,反向传播时整个权重矩阵变成nan,但损失打印出来还是一行正常的数字,因为nan的比较结果永远是假。这个错误我查了一个下午。

cache用可变字典传出,是为了反向传播时能拿到X、z1、a1、p四个中间量。用返回值打包也行,但训练循环里会多一层解包,代码更难读。

3.3 反向传播:从 softmax 加交叉熵的合并梯度开始

softmax 和交叉熵合在一起求导,结果非常干净:预测概率向量,把正确类那一项减一,再除以 batch size。这一步不需要手推链式法则到底,但要知道为什么可以这么写——因为交叉熵对 logits 的梯度恰好是p - onehot,而 softmax 的雅可比矩阵和它相乘后正好约掉。

def loss_and_grad(self, X, y): cache = {} p = self.forward(X, cache) n = X.shape[0] # 交叉熵损失,加 1e-12 防止 log(0) loss = -np.mean(np.log(p[np.arange(n), y] + 1e-12)) # dL/dz2 = (p - onehot) / n dz2 = p.copy() dz2[np.arange(n), y] -= 1.0 dz2 /= n grads = {} grads["W2"] = cache["a1"].T @ dz2 grads["b2"] = dz2.sum(axis=0) da1 = dz2 @ self.W2.T dz1 = da1 * (cache["z1"] > 0.0) # ReLU 导数,注意用 z1 不是 a1 grads["W1"] = X.T @ dz1 grads["b1"] = dz1.sum(axis=0) return loss, grads

ReLU 的导数条件写z1 > 0而不是a1 > 0,两者在正值区间等价,但用z1语义更清楚,也避免了浮点比较时a1恰好为极小正数却被判成零的边界情况。dz1里乘的是一个布尔矩阵,numpy 会自动转成 0/1,比写np.where快一点。

3.4 初始化和学习率:两个最容易拍脑袋的参数

He 初始化里的方差取2/n_in,是因为 ReLU 把一半的激活值压成零,等效于把每层的有效 fan-in 砍半。用 Xavier 的1/n_in会让第二层输入偏小,训练初期损失下降很慢,容易被误判成「网络学不动」。

学习率用带动量的 SGD,初始 0.05,动量 0.9。这个任务样本量小、损失面比较平滑,0.05 已经能在两百轮内收敛;上 Adam 当然更快,但手写 Adam 要多维护两个状态字典,第一版没必要。判断学习率是否合适的土办法是看前二十轮的损失曲线:如果震荡幅度超过均值的三成,说明步长太大;如果二十轮掉了不到 0.1,说明太小。

class Trainer: def __init__(self, net, lr=0.05, momentum=0.9): self.net = net self.lr = lr self.momentum = momentum self.velocity = {k: np.zeros_like(getattr(net, k)) for k in net.keys} def step(self, grads): for k in self.net.keys: self.velocity[k] = self.momentum * self.velocity[k] - self.lr * grads[k] setattr(self.net, k, getattr(self.net, k) + self.velocity[k])

动量项存的是速度而不是梯度的指数平均,写法上少一次除法,效果一样。注意setattr更新的是新数组,不是原地修改,如果有别处持有旧数组的引用,会读到过期权重。这个坑在保存模型快照时尤其容易踩到。

4. 数据、训练、评估:让车自己跑完一圈

有了环境和网络,剩下的是把人的操作转成监督信号。这一段最容易被低估:很多人以为网络结构决定上限,实际上标签质量决定下限,尤其是转向这类连续决策被离散成三类之后,标注噪声会被放大。

4.1 人工示教:三千帧怎么录才有代表性

录制流程是:开一局,用方向键跑,每帧把观测和当前动作存进列表,撞墙或跑完一圈就重置。我一般录五到八圈,凑够三千帧左右。关键不是帧数多,而是覆盖要全:直道、入弯、弯心、出弯各占一定比例,左右弯都要有,还要故意在贴墙位置走一段,让网络见过「已经偏了要回正」的状态。

def record(policy, episodes=8, out_path="demo.npz"): X, Y = [], [] for ep in range(episodes): env = RaceEnv(seed=ep) traj, progress = run_episode(env, policy, render=True) X.extend([o for o, _ in traj]) Y.extend([a for _, a in traj]) print(f"episode {ep} progress={progress:.2f} frames={len(traj)}") np.savez_compressed(out_path, X=np.array(X, dtype=np.float32), Y=np.array(Y, dtype=np.int64))

seed=ep让每局的赛道略有不同,但幅度不能大,否则新手示教时自己都开不好,录进来的全是碰撞前的犹豫样本。录制阶段一定要开渲染,因为键盘输入的采样依赖事件循环,无渲染模式下按键会丢。

数据增强只做一种:左右镜像。把五根射线的顺序反转、角度符号取反,动作里 0 和 2 互换,1 保持不变。这一招能把有效样本翻倍,而且天然缓解左右弯样本不均衡。注意镜像时速度那一维不变,位置量不要放进观测,否则镜像就没法做了。

4.2 训练循环:先在一个 batch 上过拟合

正式训练前,我习惯先拿一个 64 帧的小 batch 反复训,看损失能不能压到 0.01 以下。压不下去说明网络或反向传播写错了,这时候去调超参没有意义。压得下去再上全量数据,训练过程基本就是抄作业:

def train(X, Y, epochs=300, batch_size=64, lr=0.05, seed=0): rng = np.random.default_rng(seed) net = Net(seed=seed) trainer = Trainer(net, lr=lr) for ep in range(epochs): idx = rng.permutation(len(X)) total = 0.0 for i in range(0, len(X), batch_size): b = idx[i:i + batch_size] loss, grads = net.loss_and_grad(X[b], Y[b]) trainer.step(grads) total += loss * len(b) if ep % 20 == 0: acc = (net.forward(X).argmax(axis=1) == Y).mean() print(f"epoch {ep} loss={total / len(X):.4f} acc={acc:.3f}") return net

batch_size=64在三千样本上是四十多步一轮,三百轮总共一万多次更新,CPU 上几十秒跑完。每轮重新打乱索引很重要,固定顺序会让动量项沿着样本顺序积累出方向性偏差,表现为损失曲线出现周期性抖动。

训练集准确率停在 0.85 附近是正常的,因为人工示教本身有不一致的地方:同一个位置,这次按直行下次按左转。不要为了刷准确率去加大网络,那样只会把噪声也记住。

4.3 评估:别只看训练集准确率

评估要在没见过的赛道上跑完整回合,统计三个量:单圈完成率、平均出界次数、平均完成时间。我一般跑十条随机赛道,每条跑三次取平均,因为神经网络策略是确定性的,同一赛道重复跑结果一样,除非给观测加噪声。

指标计算方式及格线
完成率跑完全程的赛道数 / 总赛道数七成以上
出界次数单局碰撞次数均值小于两次
平均圈速完成局的平均帧数 / 60与人工示教差距两成内

评估时一定要关渲染跑。开着渲染跑十条赛道要几分钟,关掉只要几秒,而且能顺带验证物理步长是否和录制时一致。我吃过这个亏:渲染模式表现正常,无渲染模式直接撞墙,查了两小时才发现是dt取错了。

5. 避坑与排查:车打转、损失不降、换道就翻车

前面几章的代码跑通不难,难的是跑通之后车不听话。下面四条是我实际踩过的,每条都按现象、原因、解决三段说清楚,遇到同类问题可以直接对号入座。

5.1 一启动就满舵转圈

现象很直观:车刚放上赛道就往一个方向打死方向盘,在起点附近画圆,几秒后撞墙,重启还是同一个方向。

原因通常有两个。一是观测没归一化,射线距离是几十到两百多的像素值,速度也是两百量级,而标签只有三类,第一层权重的梯度被大数主导,训练出来的决策边界基本只由速度那一维决定,转向信息被淹没。二是标签严重不均衡,直行样本占到七成以上,网络学到的偷懒解就是「永远直行」,只有在速度极高的少数样本上才被迫输出转向,于是低速时表现成满舵。判断方法是打印每个类别的样本数,再看网络在直道上的输出概率分布。

解决分两步:所有观测量缩放到 0 到 1 之间,射线距离除以最大探测距离并截断,速度除以最大速度;然后对左转和右转样本做 1.5 到 2 倍的过采样,或者直接在损失里给少数类加权。加权比过采样更稳,因为过采样会让同一个样本在一个 batch 里重复出现,动量项容易过冲。

5.2 损失卡在 1.0986 一动不动

这个数字是ln(3),也就是三类均匀分布时的交叉熵。看到它就知道网络输出已经完全退化成常数,不管输入是什么都给出相同概率。

原因按概率排序:标签索引和输出列错位,比如键盘策略返回 0 表示左,但训练时把 0 当成了右;softmax 里没减最大值,某个异常观测让exp溢出成inf,后续梯度全变nan,而nan参与求平均后损失打印出来可能还是有限值;学习率过大,第一轮就把权重推到饱和区,ReLU 全部失活。第三种情况最隐蔽,因为损失不是变成nan而是停在一个正常数字上。

排查顺序是:先取一个 batch,手工算一遍前向,看正确类的概率是不是接近均匀;再打印每层梯度的范数,如果W1的梯度范数是零而W2的不是,基本可以确定 ReLU 全灭;最后检查标签取值范围。确认无误后把学习率降到 0.01 重跑二十轮,看损失是否开始下降。

5.3 训练集满分,换条新赛道就撞墙

现象是训练集准确率 0.98,评估时在训练用的那条赛道上能跑完,换一条曲率稍大的赛道,第一个弯就出界。

原因是过拟合到了赛道的绝对几何上。虽然观测里只有射线距离,看起来是相对量,但五根射线的角度分布固定,网络完全可以通过「左前 30 度读数比右前 30 度小多少」这类组合记住某条赛道的弯道序列。样本之间高度相关(相邻帧几乎一样)会放大这个问题,实际有效样本量可能只有几百。

解决办法是赛道参数随机化:训练时每条新赛道随机采样a1、a2和初始相位,让网络见到足够多的弯道形状。同时给观测加一点高斯噪声,标准差取 0.02 左右,相当于模拟传感器误差,能显著提升泛化。另一个有效手段是把射线从五根加到七根,角度间隔缩到二十度,分辨率上去之后,网络对「弯有多急」的判断会准很多。

5.4 渲染模式正常,无渲染模式跑飞

同一个模型,开着窗口跑能跑完一圈,关掉渲染跑十次撞十次,而且撞的位置还不固定。

原因是物理步长不一致。渲染模式里主循环被clock.tick(60)限速,实际dt在 0.015 到 0.018 之间浮动;关掉渲染后循环跑到几百帧每秒,如果dt还是取实际耗时,每步只有一两毫秒,同样一串动作跑出的轨迹完全不同,因为车辆模型的转向角速度对dt是非线性的。

解决就是前面强调过的:物理步长固定成 1/60,与实际帧率解耦。渲染时用累加器控制,一帧内跑零到多次物理步。改完之后两种模式的结果应该逐帧一致,如果不一致,说明还有别的地方引用了真实耗时,比如阻力项里用了dt的平方。

提示:改完固定步长后,先跑一次「录制轨迹回放」,用录下来的动作序列在两种模式下各跑一遍,比较终点坐标。误差超过一个像素就说明还有耦合。

6. 进阶:把标签扔掉,用进化策略直接优化圈速

监督学习的天花板是示教者的水平,人开得多好,车最多也就那样。想再往上走一步,就得把标签扔掉,直接拿圈速当目标。这个规模下最省事的是进化策略:不动网络结构,只对两百个参数加高斯噪声,跑一遍看谁跑得远,按适应度加权更新。

def evaluate(net, track_seed, max_steps=2500): env = RaceEnv(seed=track_seed) obs = env.reset() for _ in range(max_steps): action = int(np.argmax(net.forward(obs[None, :])[0])) obs, done = env.step(action, 1.0 / 60.0) if done: break return env.lap_progress() # 0 到 1,跑完为 1 def es_train(net, generations=60, pop=24, sigma=0.05, lr=0.03): rng = np.random.default_rng(7) for gen in range(generations): noises, rewards = [], [] for _ in range(pop): eps = {k: rng.normal(0, 1, getattr(net, k).shape) for k in net.keys} backup = {k: getattr(net, k).copy() for k in net.keys} for k in net.keys: # 临时加扰动 setattr(net, k, getattr(net, k) + sigma * eps[k]) rewards.append(evaluate(net, track_seed=gen)) for k in net.keys: # 还原 setattr(net, k, backup[k]) noises.append(eps) r = np.array(rewards) r = (r - r.mean()) / (r.std() + 1e-8) # 标准化,抑制方差 for k in net.keys: grad = sum(rr * e[k] for rr, e in zip(r, noises)) / (pop * sigma) setattr(net, k, getattr(net, k) + lr * grad) print(f"gen {gen} best={max(rewards):.3f} mean={np.mean(rewards):.3f}") return net

pop=24是一轮采样二十四个扰动版本,每个版本完整跑一局,六十轮就是一千四百多局,无渲染模式下几分钟能跑完。sigma控制探索范围,零点零五对两百个参数来说刚好,太大会把已经学好的转向策略打散,太小则二十四个样本之间差异不明显,标准化之后全是噪声。track_seed=gen让每一代换一条赛道,相当于把随机化直接嵌进适应度里,泛化能力比监督学习那套自然得多。

从监督学习的权重出发去做进化策略,收敛比随机初始化快一个数量级,这是我最推荐的组合方式:先示教跑通,再进化提速。两者的评估口径要统一,都用lap_progress,不要一个用准确率一个用圈速,否则没法比较。

最后说一个习惯。每次改网络结构、换传感器数量或者调学习率,我都强制跑三个随机种子,每个种子只看前五十轮的损失曲线和完成率,任何一次结果离群就停下来查,而不是取最好那次写进笔记。这套流程救过我很多次,因为神经网络在这个规模下的随机性比想象中大得多,单次结果好看往往只是运气。希望帮到你。

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