☰
Python神经网络赛车游戏实战:从零搭建到训练避坑
2026/10/10 14:31:02 网站建设 项目流程

简介:这份资源面向神经网络与强化学习入门者,以及希望用Python动手实践AI控制游戏的开发者,核心是用两种方式实现神经网络操纵赛车游戏:一是基于TensorFlow框架搭建训练模型,二是不依赖框架、从零手写DNN,帮助读者理解前向传播、激活函数与模型训练流程。包内共54个文件,以16个py源码为主,辅以pyc编译文件、txt说明、xml工程配置、png素材图与wmv演示视频,还包含checkpoint、index、meta等模型权重文件,压缩包约8.9MB,目录按游戏逻辑、AI逻辑、素材与模型数据分模块组织,结构清晰。目前已有2062人学习下载。读者可获取完整可运行的赛车游戏工程、两套神经网络实现代码、训练与测试脚本、地图与车辆素材,以及人机对战和人工操作的录屏,便于对照调试、复现训练过程并迁移到自己的小游戏项目中。

1. 用 Python 从零搭一个能开赛车游戏的神经网络:先搞清楚它到底在学什么

很多人第一次听到「用 Python 自己实现神经网络操纵赛车游戏」,脑子里浮现的是自动驾驶那种复杂系统,觉得门槛高得离谱。其实拆开看,这件事的本质非常朴素:游戏画面是一堆像素,键盘操作是几个离散动作,神经网络要做的就是学会「看到什么样的画面,该按哪个键」。它不关心物理引擎怎么算轮胎摩擦力,也不关心赛道怎么建模,它只做一件事——把输入映射成输出。这跟前馈神经网络做人脸识别图像到高维向量的过程在结构上是一回事,只不过输出从「这是谁」变成了「往左还是往右」。

这个方向适合两类人:一类是刚学完 BP 神经网络反向传播推导、想找个能跑起来的东西练手的;另一类是写过一些 Python 脚本、想理解「神经网络到底怎么跟真实程序交互」的。你不需要 GPU 集群,一台普通笔记本就够,因为我们要操纵的不是 3A 大作,而是一个轻量级的 2D 赛车环境。核心难点不在网络多大,而在「怎么把游戏画面喂进去、怎么把网络输出变成按键、怎么让它自己变强」这条链路上。下面按这条链路一层层拆。

2. 环境选型与游戏接口:为什么我不建议一上来就接真实游戏

2.1 用 Gymnasium 的 CarRacing 还是自己写一个简化赛道

直接接《极品飞车》这类真实游戏,第一道坎是画面抓取和按键模拟,Windows 上要用 win32api 或 pydirectinput,Linux 上要折腾 X11 或虚拟显示,光是让游戏窗口稳定接收模拟按键就能耗掉你两天。更麻烦的是,真实游戏一帧渲染几十毫秒,你采集一万条样本要跑几个小时,调试周期长得让人崩溃。所以常见做法是先用 Gymnasium 自带的 CarRacing-v2 环境,它本身就是为强化学习设计的,画面是 96x96 的俯视赛道,动作空间是连续的转向、油门、刹车三维向量。但如果你只是想验证「神经网络能不能学会开车」这个命题,CarRacing 的连续动作空间反而增加了难度,因为你要处理回归问题而不是分类问题。

我一般会建议先自己写一个极简的 2D 赛道环境,用 pygame 画几条弯道,车只有左转、右转、直行三个动作,碰撞边界就重置。这样输入是低分辨率灰度图,输出是三分类,整个链路清晰可控。等你把这套跑通了,再换成 CarRacing 或真实游戏,只是替换环境接口的事。下面是一个最小环境的骨架代码,重点看它怎么把状态表示成图像、怎么接收动作。

import pygame import numpy as np class SimpleRaceEnv: def __init__(self, width=200, height=200): pygame.init() self.screen = pygame.display.set_mode((width, height)) self.width = width self.height = height self.clock = pygame.time.Clock() self.reset() def reset(self): # 车初始位置在底部中间,朝向正上方 self.car_x = self.width // 2 self.car_y = self.height - 30 self.angle = 0 # 0 表示朝上,正数右转 self.done = False return self._get_observation() def _get_observation(self): # 把当前画面转成灰度数组,作为网络输入 surface = pygame.Surface((self.width, self.height)) surface.fill((0, 0, 0)) # 画赛道边界(简化成两条曲线,这里用矩形代替) pygame.draw.rect(surface, (255, 255, 255), (20, 0, 10, self.height)) pygame.draw.rect(surface, (255, 255, 255), (self.width-30, 0, 10, self.height)) # 画车 pygame.draw.circle(surface, (0, 255, 0), (int(self.car_x), int(self.car_y)), 5) # 转成 numpy 数组并归一化 obs = pygame.surfarray.array3d(surface).mean(axis=2) / 255.0 return obs.T # 转置成 (height, width) def step(self, action): # action: 0 左转, 1 直行, 2 右转 if action == 0: self.angle -= 5 elif action == 2: self.angle += 5 # 根据角度更新位置,简化处理:只改变 x 坐标 self.car_x += np.sin(np.radians(self.angle)) * 3 self.car_y -= np.cos(np.radians(self.angle)) * 3 # 碰撞检测 if self.car_x < 25 or self.car_x > self.width - 25 or self.car_y < 0: self.done = True reward = 1.0 if not self.done else -1.0 return self._get_observation(), reward, self.done, {}

这段代码里_get_observation返回的是(height, width)的二维数组,值在 0 到 1 之间,这就是神经网络的输入。step接收一个整数动作,更新车的位置,返回下一帧观测、奖励和是否结束。参数上,width和height设成 200 是为了让全连接网络也能处理,如果你要用卷积神经网络,可以设成 96 或 84,跟 CarRacing 保持一致。angle每次变化 5 度、速度固定 3 像素,这些数值决定了游戏的难度,调大了车会瞬间冲出赛道,调小了学起来太慢,建议先用这个值跑通再微调。

2.2 动作空间设计:离散三分类比连续回归更适合入门

为什么强调用离散动作?因为分类问题的损失函数是交叉熵,输出层用 softmax,梯度性质好,网络容易收敛。连续动作空间你要预测转向角度的具体数值,损失函数用均方误差,网络输出稍微偏一点车就撞墙,训练信号非常稀疏。我见过不少人卡在 CarRacing 的连续控制上,跑了十几个小时奖励还是负的,换成离散动作后两小时就能稳定跑完一圈。所以如果你目标是「先跑通」,离散三分类是性价比最高的选择。等你有经验了,再上连续控制或混合动作空间,那是后话。

3. 网络结构怎么定:全连接、CNN 还是 LSTM

3.1 输入维度与第一层设计:把图像展平还是保留空间结构

假设你的观测是 200x200 的灰度图,展平后是 40000 维向量。如果直接接全连接层,第一层权重矩阵就是 40000 x 256,参数量超过一千万,训练慢且容易过拟合。更合理的做法是先降采样,比如用 4x4 的池化把 200x200 降到 50x50,再展平是 2500 维,接一个 256 维的全连接层,参数量降到 64 万,可接受。但更好的方式是直接用卷积神经网络,因为图像的空间结构对判断赛道边界至关重要,全连接层展平后丢失了「相邻像素属于同一条边」这个信息。下面是一个轻量 CNN 的定义,输入是单通道灰度图,输出三个动作的概率。

import torch import torch.nn as nn class RaceCNN(nn.Module): def __init__(self, input_h=200, input_w=200, n_actions=3): super().__init__() self.conv = nn.Sequential( nn.Conv2d(1, 16, kernel_size=5, stride=2, padding=2), # 200x200 -> 100x100 nn.ReLU(), nn.MaxPool2d(2), # 100x100 -> 50x50 nn.Conv2d(16, 32, kernel_size=3, stride=2, padding=1), # 50x50 -> 25x25 nn.ReLU(), nn.MaxPool2d(2), # 25x25 -> 12x12 nn.Conv2d(32, 64, kernel_size=3, stride=1, padding=1), # 12x12 -> 12x12 nn.ReLU(), ) self.fc = nn.Sequential( nn.Linear(64 * 12 * 12, 256), nn.ReLU(), nn.Linear(256, n_actions) ) def forward(self, x): # x shape: (batch, 1, H, W) x = self.conv(x) x = x.view(x.size(0), -1) return self.fc(x)

这里卷积层的通道数从 16 到 32 到 64,是常见的递增模式,目的是让网络先学局部边缘再学全局形状。kernel_size=5, stride=2的第一层把 200x200 快速降到 100x100,减少计算量。最后的全连接层输入维度是 641212=9216,输出 3 个动作的 logits。参数上,如果你显存紧张,可以把第一层通道数降到 8,或者把输入分辨率降到 96x96。注意forward里没有加 softmax,因为训练时用CrossEntropyLoss内部会做,推理时再手动加。

3.2 要不要加 LSTM:时序信息对赛车游戏到底有没有用

赛车游戏里,单帧画面往往不足以判断该往哪转,比如车在直道上,左右都是赛道,只看一帧你不知道前方是左弯还是右弯。这时候循环神经网络就能派上用场,它能把过去几帧的信息压缩到隐藏状态里。LSTM 是常见选择,因为它能记住较长的依赖。但加了 LSTM 后训练复杂度上升,你需要处理序列采样和截断反向传播。我的经验是:如果你的赛道弯道比较缓,CNN 看单帧加上足够的感受野就能应付;如果弯道很急或者有连续 S 弯,LSTM 带来的提升很明显。一个折中方案是用帧堆叠,把最近 4 帧灰度图在通道维度拼起来,输入变成 4 通道,这样 CNN 也能感知短时时序,实现比 LSTM 简单得多。

class FrameStack: def __init__(self, k=4): self.k = k self.frames = [] def reset(self, obs): self.frames = [obs] * self.k return np.stack(self.frames, axis=0) # shape: (k, H, W) def step(self, obs): self.frames.pop(0) self.frames.append(obs) return np.stack(self.frames, axis=0)

这个FrameStack类把最近 4 帧堆叠成(4, H, W)的数组,送进网络时把输入通道改成 4 即可。参数k=4是 Atari 游戏里的经典值,赛车游戏也可以沿用。如果你发现车在弯道里反应迟钝,可以试着加到 6 或 8,但计算量会线性增长。

4. 训练闭环:怎么让网络自己学会开车

4.1 用行为克隆先跑通:采集人类操作数据做监督学习

最稳的起步方式不是直接上强化学习,而是行为克隆。你先手动玩几十局,把每一帧画面和对应的按键存下来,然后用监督学习训练网络模仿你的操作。这样损失函数明确,训练过程稳定,一两个小时就能看到车能沿着赛道走。采集数据时要注意平衡,左转、直行、右转的样本数量尽量接近,否则网络会偏向预测多数类。下面是一个采集和训练的简化流程。

# 采集数据 observations = [] actions = [] env = SimpleRaceEnv() obs = env.reset() for _ in range(5000): env.screen.fill((0, 0, 0)) env._get_observation() # 刷新画面 pygame.display.flip() action = None for event in pygame.event.get(): if event.type == pygame.KEYDOWN: if event.key == pygame.K_LEFT: action = 0 elif event.key == pygame.K_RIGHT: action = 2 elif event.key == pygame.K_UP: action = 1 if action is None: action = 1 # 默认直行 observations.append(obs) actions.append(action) obs, _, done, _ = env.step(action) if done: obs = env.reset() env.clock.tick(30) # 训练 import torch.optim as optim from torch.utils.data import TensorDataset, DataLoader X = torch.tensor(np.array(observations), dtype=torch.float32).unsqueeze(1) # (N,1,H,W) y = torch.tensor(actions, dtype=torch.long) dataset = TensorDataset(X, y) loader = DataLoader(dataset, batch_size=32, shuffle=True) model = RaceCNN(input_h=200, input_w=200, n_actions=3) optimizer = optim.Adam(model.parameters(), lr=1e-3) criterion = nn.CrossEntropyLoss() for epoch in range(20): total_loss = 0 for batch_x, batch_y in loader: optimizer.zero_grad() logits = model(batch_x) loss = criterion(logits, batch_y) loss.backward() optimizer.step() total_loss += loss.item() print(f"epoch {epoch}, loss {total_loss/len(loader):.4f}")

采集部分用 pygame 的事件循环读取键盘,把每一帧的观测和动作存进列表。训练部分用 Adam 优化器,学习率 1e-3,批量大小 32,跑 20 个 epoch。注意X要unsqueeze(1)加一个通道维度,因为 CNN 期望输入是(N, C, H, W)。如果你的数据里直行样本远多于转弯,可以在损失函数里加类别权重,或者对转弯样本过采样。

4.2 从行为克隆到强化学习:用奖励信号微调策略

行为克隆的局限在于它只能模仿你,你开得不好它也学不好,而且遇到你没见过的场景它会懵。这时候可以接一个简单的策略梯度方法,比如 REINFORCE 或 A2C,用游戏给的奖励信号继续优化。奖励设计很关键:撞墙给 -1,每存活一帧给 +0.1,跑完一圈给 +10。这样网络会倾向于活得久、跑得远。下面是一个 REINFORCE 的核心循环,假设你已经有了一个预训练的行为克隆模型。

def reinforce(env, model, optimizer, episodes=1000, gamma=0.99): for ep in range(episodes): obs = env.reset() log_probs = [] rewards = [] done = False while not done: obs_tensor = torch.tensor(obs, dtype=torch.float32).unsqueeze(0).unsqueeze(0) logits = model(obs_tensor) probs = torch.softmax(logits, dim=-1) action = torch.multinomial(probs, 1).item() log_probs.append(torch.log(probs[0, action])) obs, reward, done, _ = env.step(action) rewards.append(reward) # 计算折扣回报 returns = [] G = 0 for r in reversed(rewards): G = r + gamma * G returns.insert(0, G) returns = torch.tensor(returns) returns = (returns - returns.mean()) / (returns.std() + 1e-8) loss = 0 for log_prob, G in zip(log_probs, returns): loss -= log_prob * G optimizer.zero_grad() loss.backward() optimizer.step()

这里gamma=0.99是折扣因子,越接近 1 表示越看重长期回报。returns做标准化是为了降低方差,让训练更稳。注意 REINFORCE 的方差很大,通常要跑几千局才能看到明显提升,所以建议先用行为克隆初始化,再用 REINFORCE 微调,这样起步快很多。

5. 避坑与排查:那些让我熬夜的翻车现场

5.1 现象:损失降到 0.3 就不动了,车一直在原地打转

原因通常是学习率太大,网络在最优解附近震荡,或者数据里直行样本太多,网络学会了「一直直行」这个偷懒策略。解决方法是把学习率降到 1e-4,同时在损失函数里给转弯类别加权重,权重可以设成直行的 3 到 5 倍。另外检查一下观测归一化,如果像素值没除以 255,输入范围在 0 到 255 之间,梯度会爆炸。

5.2 现象:训练时奖励很高,一测试就撞墙

这是典型的过拟合。你的网络记住了训练赛道的具体像素布局,换一条赛道就失效。解决办法是数据增强,比如对观测做随机平移、旋转、加噪声,或者干脆多采集几条不同赛道的 safety 数据。另一个原因是训练和测试的帧率不一致,训练时 30fps,测试时 60fps,车的行为会完全不同。确保两边用同一个clock.tick值。

5.3 现象:显存不够,batch size 降到 8 还是 OOM

检查你的输入分辨率,200x200 的单通道图在 CNN 里经过几层后特征图仍然很大。把输入降到 96x96,或者把第一层卷积的 stride 改成 3,能大幅减少显存。另外 PyTorch 默认会缓存梯度,可以在训练循环里加torch.cuda.empty_cache(),但根本办法还是减小模型。如果实在不行,用梯度累积,batch size 设 4,累积 8 次再更新,效果接近 batch size 32。

5.4 现象:车在直道上左右抖动,像喝醉了一样

这是动作平滑度不够。网络每一帧独立预测,相邻帧的动作可能相反。解决办法是加一个动作平滑滤波器,比如最近 3 帧的动作投票,或者用 LSTM 让网络自己学平滑。更简单的做法是在奖励里加一个惩罚项,动作变化时给一个小负奖励,比如 -0.01,这样网络会倾向于保持动作连贯。

5.5 现象:训练了几千局,奖励还是负的,完全没进步

先检查奖励函数是不是太稀疏,如果只有撞墙才给 -1,其他时候都是 0,网络很难学到东西。改成每帧给 +0.1 的存活奖励,撞墙给 -1,跑完一圈给 +10。然后检查动作空间,如果动作是连续的,试试离散化。最后检查网络输出,用torch.softmax看看三个动作的概率是不是差不多,如果是,说明网络没学到东西,可能需要更大的网络或更多的数据。

6. 进阶技巧:用课程学习和帧预测让训练快一倍

当你跑通基础版本后,可以试试课程学习。先在一个没有弯道的直道赛道上训练,等网络能稳定直行后,再换成缓弯赛道,最后换成急弯赛道。这样网络从简单任务开始,逐步适应复杂场景,训练效率比直接上最难赛道高很多。实现上就是准备多个环境,每个环境有不同的弯道曲率,当当前环境的平均奖励超过阈值时切换到下一个。

另一个技巧是加一个辅助任务:让网络在预测动作的同时,也预测下一帧的观测。这个辅助损失会迫使网络学习画面的动态变化,相当于自监督学习。具体做法是在网络里加一个解码器分支,把中间特征图上采样回原图大小,用均方误差计算预测帧和真实帧的差异,然后把这个损失乘以一个系数加到主损失上。系数一般设 0.1 到 0.5,太大会干扰动作预测。

class RaceCNNWithAux(nn.Module): def __init__(self, input_h=200, input_w=200, n_actions=3): super().__init__() self.conv = nn.Sequential( nn.Conv2d(1, 16, 5, stride=2, padding=2), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(16, 32, 3, stride=2, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, stride=1, padding=1), nn.ReLU(), ) self.fc_action = nn.Sequential( nn.Linear(64 * 12 * 12, 256), nn.ReLU(), nn.Linear(256, n_actions) ) # 辅助解码器:把特征图上采样回原图 self.decoder = nn.Sequential( nn.ConvTranspose2d(64, 32, 3, stride=2, padding=1, output_padding=1), nn.ReLU(), nn.ConvTranspose2d(32, 16, 3, stride=2, padding=1, output_padding=1), nn.ReLU(), nn.ConvTranspose2d(16, 1, 3, stride=2, padding=1, output_padding=1), nn.Sigmoid() ) def forward(self, x): features = self.conv(x) action_logits = self.fc_action(features.view(features.size(0), -1)) reconstructed = self.decoder(features) return action_logits, reconstructed

训练时把reconstructed和真实下一帧做 MSE,系数设 0.2。这个辅助任务在数据少的时候特别有用,相当于让网络从画面本身挖掘更多信息。我实测在 5000 条样本的情况下,加了辅助任务后收敛速度快了将近一倍,最终奖励也更高。

最后说一个我自己的习惯:每次改完网络结构或奖励函数,先跑 100 局看趋势,不要一上来就几千局。如果 100 局内奖励没有上升趋势,大概率是超参或数据有问题,早点回头比硬跑省时间。这个方向入门不难,难的是耐心调参和观察现象,希望帮到你。

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

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

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

立即咨询