☰
手写反向传播:从零实现神经网络自动驾驶赛车游戏
2026/10/14 13:58:13 网站建设 项目流程

简介:一套用Python从零实现神经网络操纵赛车游戏的实战项目,面向神经网络与强化学习入门者,也适合希望通过趣味场景理解模型训练与推理过程的开发者。项目同时提供TensorFlow框架版本与自手写神经网络实现,并用同一游戏环境对比两者效果,可加深对算法本质的认识。压缩包共54个文件、约8.9MB,其中16个py为游戏逻辑与训练脚本,10个pyc为编译缓存,9个txt为数据记录与说明文档,7个xml为项目配置,5个png为地图与车辆素材,2个wmv为人工操作与人机比赛录屏,另含TensorFlow模型检查点文件,目录结构按功能模块划分,便于快速定位与复用。已有2062人学习下载。资源内提供完整可运行的赛车游戏代码、神经网络训练脚本、已有模型数据、操作录屏和地图素材,可帮助读者从游戏环境搭建到模型训练一气呵成,进而调整网络结构或控制策略,适合作为神经网络入门实践与课程设计参考。

1. 不用深度学习框架也能让赛车自己跑:先认清这条路要付出什么

用 Python 自己实现神经网络操纵赛车游戏,核心不是“赛车”也不是“游戏”,而是丢掉 TensorFlow、PyTorch 这类现成训练框架,把神经网络的前向传播、反向传播和参数更新全部写在自己的代码里,再让这个网络接上赛车游戏,用屏幕画面换方向盘动作。这件事能跑通,意味着你同时打通了两层认知:反向传播每一步到底在算什么,以及一个实时交互系统里数据、延迟、过拟合这些脏问题是怎么互相纠缠的。常见错觉是觉得“手写网络很难”,真正劝退多数人的其实是数据采集、图像裁剪和控制循环的稳定性。这篇文章按我实际做过的方案来拆:选型、采集、网络、训练、避坑、进阶,所有代码基于 numpy,不依赖任何深度学习库。适合想做课程设计、竞赛 Demo,或者单纯想把神经网络黑匣子打开看一眼的开发者。

2. 方案选型与数据采集:为什么先从模仿学习切入

2.1 先选模仿学习而不是强化学习:训练稳定的关键

提到让 AI 玩游戏,很多人第一反应是强化学习,想象网络自己在赛道上试错,跑着跑着就学会过弯了。但如果你坚持手写网络,这个想法会立刻撞上现实:策略梯度要处理奖励设计、探索与利用、回合方差,还要保证收集到足够多的负样本。任何一个环节出错,你很难判断到底是网络写错了,还是奖励函数不合理,还是采样过程有 bug。调试成本会远远超过网络本身。

所以常见做法是先用模仿学习,也叫监督学习路线:人先手动开几圈游戏,把屏幕画面和对应按键同时录制下来,形成一批带标签数据。网络学到的是“看到这种画面,就打这个方向”。这本质是一个标准的图像分类问题,输入是一帧降采样后的灰度图,输出是左、直、右三类动作的概率。训练时的反馈非常直接:loss 在下降,说明分类在变准,游戏表现基本也会跟着变好。

这个选型直接决定了整个项目的代码结构,后面所有模块都是围绕它展开的:采集器负责产生数据集,网络负责前向与反向,训练循环负责更新权重,推理脚本负责把权重接回游戏。强化学习可以作为后续进阶方向,但第一版闭环不要碰。手写网络的前提下,先把监督学习跑通,等于先把整个工程链路验证了一遍,之后再换任何算法都不会觉得无从下手。

2.2 采集驾驶样本:截图、灰度化、动作标注与保存

游戏环境的选择有一个原则:能窗口化运行,分辨率可以固定,画面元素尽量简单。某开发者最初用的是一款开源的竞速 Demo,画面里没有复杂光影,赛道边缘和路面对比度高,HUD 信息很少。这种环境采集出来的数据非常干净,模型不需要从一堆无关像素里硬找特征。如果一上来就用光影复杂、镜头抖动剧烈的大型赛车游戏,数据维度和噪声都会翻倍,代价不划算。

采集脚本的核心逻辑是:通过屏幕截图拿到游戏画面,转成灰度图并缩小分辨率,同时记录当前按下的方向键,然后把“画面—动作”这一对数据保存下来。下面是最小可运行的采集代码。

import time import numpy as np from PIL import ImageGrab from pynput import keyboard # 0 左转,1 直行,2 右转,默认直行 action = 1 def on_press(key): global action if key == keyboard.Key.left: action = 0 elif key == keyboard.Key.right: action = 2 elif key == keyboard.Key.up: action = 1 def on_release(key): global action # 松开方向键时回到直行,保证标签接近真实意图 if key in (keyboard.Key.left, keyboard.Key.right): action = 1 listener = keyboard.Listener(on_press=on_press, on_release=on_release) listener.start() def capture_frame(region=(0, 0, 640, 480)): # 只截取游戏窗口区域,region 的四个数字按实际窗口位置调整 img = ImageGrab.grab(bbox=region) img = img.convert('L').resize((40, 30)) return np.array(img, dtype=np.float32).flatten() / 255.0 def record_session(save_path='driving_data.npz', seconds=60): images, actions = [], [] start = time.time() while time.time() - start < seconds: # 20 FPS 采样:画面变化足够连续,数据量也适中 images.append(capture_frame()) actions.append(np.eye(3)[action]) # one-hot 编码 time.sleep(1 / 20) np.savez(save_path, X=np.array(images), y=np.array(actions))

这段代码里几个参数需要仔细说。region 必须和游戏窗口完全对齐,否则会录到桌面或其他窗口;灰度化消除颜色干扰,把三个通道压缩成一个;resize 到 40x30 是因为这个分辨率对车道识别已经足够,1200 维输入对手写网络来说负担很小;像素值除以 255 归一化到 0~1,避免大数值让梯度爆炸。20 FPS 的意思是每秒记录 20 对样本,采集 60 秒就有 1200 个样本,作为第一版数据集够用。

动作用 one-hot 编码,而不是直接存整数,省去训练时转换。这里的直行默认是松开方向键,实际游戏里如果车需要持续给油,建议把油门单独绑定到某个键位,让采集者一直按着,代码里可以用同样的监听方式记录。采样帧率不宜太高,20 FPS 能保留转弯过程的连续画面,又不会让相邻帧过于相似导致数据大量冗余。

表:采集参数推荐值

参数推荐值说明
截屏区域640x480 窗口必须精确框住游戏画面
降采样尺寸40x30灰度图,展平后 1200 维
采样帧率20 FPS太低丢动作,太高样本冗余
动作编码one-hot 3 类左、直、右
采集时长60~120 秒约 1200~2400 个样本
保存格式npz内含 X 与 y 两个数组

采集时注意赛道分布均衡。只跑大直道的话,模型会变成“只会直行”;过弯数据才是训练重点。常见做法是在弯道多的赛段多跑几圈,甚至在同一个弯用不同入弯速度多录几遍。还有一个容易忽略的点:游戏窗口必须在采集期间保持前台,一旦被任务栏遮挡,截图内容会直接废掉整批数据。

提示:采集前先把窗口位置固定下来,用 set 窗口位置或直接拖动到屏幕左上角,保证 region 参数不漂移。否则模型训练出来后,换个位置跑,输入分布就变了。

3. 手写神经网络:从矩阵运算到完整的前向反向

3.1 网络形状与初始化:为什么是 1200-64-3

网络结构不需要复杂,一个单隐藏层全连接网络就足够存下“画面到方向”的映射。输入层 1200 个神经元对应 40x30 的灰度图,输出层 3 个神经元对应左、直、右,隐藏层用 64 个神经元。这里的逻辑是:数据量只有一两千条,网络深了必然过拟合;64 个神经元的表示能力已经能拟合弯道判断,参数总量约 1200x64 + 64x3 = 76992 个,手写反向传播时计算量也可控。

初始化方式是 ReLU 网络的标配:He 初始化。具体做法是用标准正态随机数乘上 sqrt(2 / 上一层神经元数),偏置初始化为 0。如果权重初始化太小,信号传到后面会逐渐衰减,loss 下降极慢;如果初始化过大,反向传播梯度也会被放大,第一轮训练就可能爆掉。这个初始化策略是为了让每一层输出的方差保持在合理范围。

import numpy as np class SimpleNet: def __init__(self, in_dim=1200, hidden_dim=64, out_dim=3): # He 初始化:针对 ReLU 设计,避免梯度消失或爆炸 self.W1 = np.random.randn(in_dim, hidden_dim) * np.sqrt(2.0 / in_dim) self.b1 = np.zeros(hidden_dim) self.W2 = np.random.randn(hidden_dim, out_dim) * np.sqrt(2.0 / hidden_dim) self.b2 = np.zeros(out_dim)

这里 W1 的形状是 1200x64,每一列对应隐藏层一个神经元的全部输入权重。sqrt(2.0 / in_dim) 是 He 初始化的缩放因子,比标准正态随机数直接使用更适合 ReLU。b1 是 64 维向量,b2 是 3 维向量。为什么偏置不是随机数而是零:偏置的作用是让神经元的输出可以平移,随机初始化反而会让初始输出缺乏稳定性,实验里零偏置配合对称输入效果更好。

3.2 前向传播:从像素到三类动作概率

前向传播是一连串矩阵乘法:输入 x 与 W1 相乘,加上 b1,过 ReLU 激活,再与 W2 相乘,加 b2,最后过 softmax,把输出变成三个概率。ReLU 的作用是引入非线性,max(0, z) 会把负数全部截断;softmax 把分值转成和为 1 的概率分布,这样输出可以直接当成“左、直、右”各自的置信度。

def forward(self, x): # x 形状 (batch_size, 1200) self.z1 = x @ self.W1 + self.b1 self.a1 = np.maximum(0, self.z1) # ReLU 激活 self.z2 = self.a1 @ self.W2 + self.b2 # softmax,减最大值防止 exp 溢出 exp_z = np.exp(self.z2 - np.max(self.z2, axis=1, keepdims=True)) self.probs = exp_z / np.sum(exp_z, axis=1, keepdims=True) return self.probs

softmax 里的数值稳定处理是关键:先减去每行最大值,再求 exp,避免 z2 中某个分值过大导致 exp 溢出成 inf。ReLU 保存在 a1,z1 也要留着,反向传播要用。forward 输出的是一个形状为 (batch_size, 3) 的概率矩阵,每行三个数加起来刚好等于 1。推理时只需要取概率最大的下标,就是当前帧应该执行的动作。

3.3 反向传播:核心梯度为什么是“预测减标签”

手写反向传播最让人头疼的通常是链式法则的推导。但如果你选择交叉熵损失搭配 softmax 输出,梯度会非常简洁:输出层的误差信号恰好等于预测概率减去 one-hot 标签,不需要手动求导 softmax。这是因为交叉熵的导数和 softmax 的导数相乘后,中间项全部消掉了。这个数学性质并不是巧合,而是分类任务里这对组合被广泛使用的原因。

def backward(self, x, y, lr): batch = x.shape[0] # softmax + 交叉熵的梯度就是 (预测值 - 标签值),除以 batch 做平均 d_z2 = (self.probs - y) / batch d_W2 = self.a1.T @ d_z2 d_b2 = np.sum(d_z2, axis=0) # ReLU 的梯度:输入大于 0 的位置导数为 1,其余为 0 d_a1 = d_z2 @ self.W2.T d_z1 = d_a1 * (self.z1 > 0) d_W1 = x.T @ d_z1 d_b1 = np.sum(d_z1, axis=0) # 梯度下降更新 self.W2 -= lr * d_W2 self.b2 -= lr * d_b2 self.W1 -= lr * d_W1 self.b1 -= lr * d_b1

注意几个细节。除以 batch 是为了让梯度变成平均值,这样 loss 定义里用 np.mean 时才保持一致。d_z2 = probs - y 这一步看似简单,但它同时包含了 softmax 求导和交叉熵求导的结果。ReLU 的梯度用 (self.z1 > 0) 作为掩码,这是一个布尔矩阵,乘在 d_a1 上,相当于把负值区域的梯度直接置零。更新公式里学习率 lr 放在最前面,如果发现 loss 发散,优先把 lr 调小而不是改网络结构。

反向传播里最容易写错的地方是矩阵乘法的方向。d_W2 用的是 a1 的转置乘 d_z2,不是 d_z2 乘 a1,因为权重矩阵的形状是 (hidden_dim, out_dim)。同样 d_W1 用 x.T 乘 d_z1。如果不小心把转置写反,程序不会报错,但梯度的形状会不匹配或者数值完全错误,训练出来的模型会像喝醉了一样乱打方向。发现这种情况,先检查每个梯度矩阵的形状是否和对应的权重矩阵一致。

4. 训练闭环:把数据集喂进去并接回游戏

4.1 训练循环与超参初值:epoch、batch、学习率怎么给

有了网络和数据,训练循环本身只有几十行。每个 epoch 先把数据随机打乱,再切成小批量,逐批前向计算 loss,反向更新权重。打乱顺序很重要,不然网络会学到采集时的路段顺序,比如先直道后弯道,测试时一旦环境顺序变化就失效。

def train_net(data_path, input_dim=1200, epochs=20, batch_size=64, lr=0.05): data = np.load(data_path) X = data['X'].reshape(-1, input_dim) y = data['y'] # one-hot 标签,形状 (N, 3) net = SimpleNet(input_dim, 64, y.shape[1]) for epoch in range(epochs): perm = np.random.permutation(len(X)) X, y = X[perm], y[perm] total_loss = 0.0 for i in range(0, len(X), batch_size): xb = X[i:i + batch_size] yb = y[i:i + batch_size] probs = net.forward(xb) # 交叉熵:加 1e-12 防止 log(0) loss = -np.mean(np.sum(yb * np.log(probs + 1e-12), axis=1)) net.backward(xb, yb, lr) total_loss += loss * len(xb) avg_loss = total_loss / len(X) print(f'epoch {epoch + 1}, loss {avg_loss:.4f}') return net net = train_net('driving_data.npz')

loss 那行的计算方式是先逐样本求交叉熵,再在 batch 内求平均,最后在打印时除以总样本数得到整个 epoch 的平均 loss。1e-12 是防止某个类别概率正好为 0 导致 log 计算出负无穷。训练完成后,net 就是接回游戏用的模型。

表:训练超参初值参考

超参数初值调整方向
epoch20~30loss 不再下降就停止
batch_size64显存小或振荡大就减半
学习率0.05NaN 减半,5 epoch 不动则加倍
隐藏层64过拟合可减,欠拟合可加
输入尺寸40x30信息不足时再加大

这个组合不是拍脑袋定的。batch_size 64 和 lr 0.05 在 1200 个样本上,一个 epoch 大约 19 次更新,权重变化幅度足够明显。如果数据集增加到 5000 张,学习率可以维持 0.05,但 epoch 数要适当减少到 15 左右,不然训练后期 loss 会在低位来回震荡。

提示:不要一上来就调网络深度。先用这套超参把闭环跑通,再根据 loss 曲线决定是否加大 hidden_dim。手写网络的调试成本比框架高很多,能不动结构就不动结构。

4.2 推理接回游戏:把神经网络输出变成方向盘动作

模型训练完成后,接回游戏是另一个闭环。推理循环做的事情和采集循环完全对称:截图、转灰度、降采样、展平,喂给网络,拿到三个动作概率,取最大值对应的类别,模拟按键。这里最需要注意的是帧率,控制循环至少要稳定在 10 FPS 以上,否则车已经过了弯道,模型才输出转弯指令,再准的网络也救不回来。

import time from pynput.keyboard import Controller, Key def run_demo(net, duration=60, region=(0, 0, 640, 480)): kb = Controller() start = time.time() while time.time() - start < duration: frame = capture_frame(region) # 复用采集函数 probs = net.forward(frame.reshape(1, -1)) action = int(np.argmax(probs)) if action == 0: # 左转 kb.press(Key.left) kb.release(Key.right) elif action == 2: # 右转 kb.press(Key.right) kb.release(Key.left) else: # 直行,松开方向键 kb.release(Key.left) kb.release(Key.right) time.sleep(1 / 15)

frame.reshape(1, -1) 是把一维向量变成形状 (1, 1200) 的矩阵,因为网络按 batch 计算,batch 维度不能丢。取 argmax 之前可以加一个简单检查:如果最大概率低于 0.4,比如画面出现训练时没见过的状态,宁可保持直行也不要乱打方向,这个规则能避免模型在陌生场景下剧烈抖动。

推理时不要复用训练脚本里的任何数据预处理逻辑,只保留 forward,不做 dropout、不做数据增强,因为训练时那些操作是为了增加泛化,推理时需要的是确定的输出。自动控制过程中最好让游戏里赛车一开始就处于直道中间位置,给模型一个最稳的启动状态,方便观察它在不同路段的反应。

4.3 判断模型是否真的在进步:不要只看 loss

损失函数下降只是说明“网络对训练数据的分类准确率在提升”,但这和“车能不能跑完一圈”是两回事。经常出现 loss 很漂亮、车却一头撞墙的情况,因为每帧判断是独立的,模型没有全局视野,也不会修正上一帧犯下的错误。所以必须回到游戏里做验证,用三个指标衡量:能否跑完一圈、平均速度多少、出界次数几次。

固定一张赛道图,每次训练完一个 epoch 就跑一次测试,记录下这三个数字。持续观察几轮就能发现,loss 和“完成圈数”并不是完全同步的,有时候 loss 还在降,但车已经能稳定过弯了;有时候 loss 降得很慢,但撞墙位置越来越靠后。这说明网络学到了有用的局部特征,只是没有立即反映在分类精度上。以实际驾驶表现为准,loss 曲线只作为训练是否稳定的参考。

测试赛道不要换,至少在一次完整调参过程中保持同一张图。换赛道会引入赛道颜色、弯道半径等额外变量,你很难判断模型变好是因为参数调对了,还是因为新赛道更简单。等模型能稳定跑完固定赛道一圈之后,再换第二张赛道验证泛化能力,那时候才有意义。

5. 实际开车时最常见的问题排查:五条血泪经验

5.1 偏移累积:训练越成功,跑偏越致命

现象:loss 已经降到 0.2 以下,模型在直道上开得稳稳当当,但一进弯道就开始往外侧偏,车身逐渐贴近路肩,最终横在墙上。回放训练数据才发现,人开车时会不断小幅修正方向,数据集里全是“正常姿态”的画面,模型从没见过“车身歪斜”的状态,自然不知道怎么救。

原因:模仿学习的默认假设是数据集覆盖了所有需要决策的状态。但人在开车时,修正动作往往很快,歪斜画面出现的时间极短,20 FPS 采样很容易漏掉这些关键帧。结果就是训练分布不完整,模型只会从正常姿态出发做动作,一旦因为微小误差偏离正常姿态,就进入了它从未学过的输入区域。

解决:采集时要有意识地录“纠偏”数据。发现车偏离理想路线时,不要立刻重开,而是先把车拉回赛道中间,这个过程里的方向盘反打操作和对应画面全部录下来。模型学到的是“画面偏左时该往右修”,而不是只会从正中间出发转向。另一种做法是训练时给输入图像加随机的水平平移扰动,模拟车身错位的情况,这个技巧成本低且效果直接。

5.2 输入没裁剪:维度上去了,效果反而下来了

现象:第一次采集时直接截全窗口 640x480,彩色图喂给网络,输入维度 640x480x3 约 92 万。训练一个 epoch 要等很久,loss 还降不下去,跑起来之后模型经常对着天空输出转向指令。

原因:游戏窗口上方有天空、计分栏、远处观众席,这些像素与驾驶决策几乎无关。彩色通道还让输入维度增加了三倍,而训练样本只有一两千,模型只能靠死记硬背部分画面,学不到真正的道路特征。

解决:裁剪到路面区域再降采样。把窗口下半部分 70% 裁出来,也就是去掉天空和 HUD,再缩到 40x21 或 28x16。维度从几十万降到几百,训练速度快一个数量级,泛化反而更好。裁剪区域不是越靠下越好,如果画面底部是车屁股或赛道护栏,这部分也属于无关信息,需要微调。

5.3 激活函数选错:全 sigmoid 让梯度消失

现象:隐藏层和输出层全用 sigmoid,训练前几个 epoch loss 确实在降,但之后卡在一个平台,比如 0.9 左右晃。打印隐藏层输出发现,大部分神经元的激活值都接近 0 或接近 1,一进入饱和区就再难更新。

原因:sigmoid 的导数最大值只有 0.25,而且输入离零点越远导数越小。两层 sigmoid 连乘,梯度传到第一层时已经衰减到接近零,权重几乎不更新。这个现象在网络层数少时还能勉强训练,但对赛车这种需要快速响应的任务,收敛速度慢本身就是致命的。

解决:隐藏层全部换成 ReLU,输出层保持 softmax。ReLU 在正半区导数为 1,梯度不会逐层衰减,训练速度快很多。换完激活函数后,初始化方案也要同步改成 He 初始化,否则 ReLU 配合默认的随机正态初始化,第一轮输出范围不稳定。这是最简单的“一改就见效”的调整。

5.4 学习率调参靠感觉:NaN 和纹丝不动的两个极端

现象:loss 某一轮突然变成 NaN,权重矩阵里出现 inf;或者连续 10 个 epoch loss 变化不超过 0.05,像是网络根本没在学。

原因:学习率太大,单次梯度更新幅度超过了最优点,权重不断在两侧弹跳,数值逐渐溢出成 NaN。学习率太小,每一步更新只移动一小点,在有限 epoch 内几乎看不到效果。手写网络里没有框架的自动梯度裁剪,所以 NaN 必须靠手工调整学习率解决。

解决:固定策略,把调参变成本能反应。学习率从 0.05 起步,出现 NaN 立即减半,直到 loss 回到正常数值范围;5 个 epoch 没明显下降就翻倍。还有一个更实用的检查方法:只取 64 个样本,用大学习率训练到过拟合,看 loss 能不能降到接近 0。如果这么少的数据都学不动,问题在代码;如果能过拟合但全量数据不动,问题在学习率或数据分布。

5.5 动作空间太细:十个键位学不会,三个键位反而能开

现象:输出层设置成 10 个类别,包括“左缓转、左急转、右缓转、右急转、油门、刹车”等组合。训练完以后,模型几乎永远输出“什么都不按”,车在起点原地打转,偶尔象征性转一下又回正。

原因:10 个动作之间的视觉差异非常小,左缓转和左急转对应的画面几乎一样,模型很难找到分类边界。同时多数时间不按键导致样本极度不均衡,softmax 收敛到把大部分样本预测成“不动”这个多数类上,注意力完全偏掉。

解决:先把动作空间压缩成 3 类:左转、直行、右转,油门固定在一个键位持续触发。3 类比 10 类更容易学,而且足够完成大多数赛车游戏中 90% 的过弯场景。等模型在 3 类动作下能稳定跑完整圈,再考虑细分:比如把左转拆成“轻度左转”和“急左转”,这时候模型已经有了基本的驾驶能力,再学更细的差异会容易得多。

6. 再进一步:帧栈输入与连续控制改造

6.1 把最近几帧叠起来:让网络看到速度

单帧图像是静态的,网络无法感知车速。同一个弯道画面,高速和低速需要的转向时机完全不同,模型只能学一个平均策略。最简单的改进是把最近 4 帧灰度图拼成一个长向量,用 collections 的 deque 维护窗口,新帧到来时拼接成输入,维度变成 4x1200=4800。网络可以从帧间位移推断出速度信息,弯道前会提前减速或更早打方向。

from collections import deque frame_buf = deque(maxlen=4) def push_and_infer(net, frame): frame_buf.append(frame) if len(frame_buf) < 4: return None # 前几帧凑不够,先保持当前动作 state = np.concatenate(list(frame_buf)) probs = net.forward(state.reshape(1, -1)) return int(np.argmax(probs))

maxlen=4 保证队列永远只保留最近 4 帧,丢掉的旧帧自动弹出。拼接顺序很重要,必须按时间先后排列,不能先拼最新的再拼旧的。输入维度变了之后,SimpleNet 的 in_dim 改成 4800,重新训练,网络结构和反向传播代码一行都不用动。

6.2 从分类改成连续输出:回归模型与验证方法

离散动作的上限是“开得还行”,连续控制的体验是“像人在开”。输出层改成 2 个神经元,一个代表方向盘转角,范围 -1 到 1,一个代表油门大小,范围 0 到 1。输出层激活从 softmax 换成 tanh,损失函数从交叉熵换成均方误差。反向传播里梯度不再是“预测减标签”,而要先乘上 tanh 的导数 1 - tanh^2,这是分类改连续时最容易写错的一步。

连续控制的难点不在网络,而在数据采集。方向键只能给出离散标签,要做连续控制,得用支持手柄输入的游戏,读取手柄摇杆或方向盘模拟量,或者用键盘映射成连续值后做平滑滤波。增益没有想象的夸张,但能让车在弯道里的走线更顺滑,入弯推头、出弯甩尾的情况明显减少。验证方法和第 4 章一致:固定赛道,每训练一个 epoch 自动跑一圈,记录是否完成、平均速度、出界次数。三个指标同时转好,才算这个自写神经网络闭环真正成立。

我自己第一次做这个项目时,网络结构基本没动,却花了两天半处理数据分布和窗口对齐的问题。最有用的一个习惯是:每次改采集、裁剪或帧率之前,先在同一个验证赛道上跑一次基线测试,把三个指标记下来再动代码。否则你很难说清楚模型表现变好,到底是网络进步了还是数据变化了。自写神经网络的价值从来不是比框架跑得快,而是当模型表现不对的时候,你能顺着每一行梯度找到原因,而不是把问题丢给黑匣子。希望帮到你。

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

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

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

立即咨询