坦克大战强化学习实战:从Q-learning到DQN的Python实现
发布时间:2026/10/2 8:40:32来源:尧图网络
简介这份资源面向希望入门强化学习与游戏AI的Python开发者以经典坦克大战为载体解决从理论到实战的落地问题。项目包含玩家操控与AI自动寻敌两套系统通过run_examples.py切换人工或智能模式run_RF.py则完整呈现强化学习模型的训练流程并借助BP神经网络与Q矩阵实现决策。压缩包共84个文件约55.7MB其中18个py源码承载核心逻辑与训练脚本41个png与2个gif提供坦克、子弹、地形等素材及运行录屏另有pyc缓存、xml配置与字体文件辅助工程运行。目前已有1561人学习下载。读者可获得可直接运行的完整工程、神经网络训练代码、pygame键盘操控示例以及分模块的目录结构便于对照调试与二次开发适合作为强化学习入门练手或课程设计参考。1. 坦克大战 强化学习一个能跑通的 Python 实战入口很多人第一次接触强化学习都是从游戏环境开始的。但大部分教程要么用 CartPole 这种过于简单的环境要么直接上 Atari 需要大量算力。坦克大战这个场景刚好卡在中间状态空间可控、动作空间离散、奖励信号清晰而且用 Python 就能完整实现不需要 GPU 集群。这个方案要解决的问题很具体让一个智能体在网格化的坦克对战环境中通过 Q-learning 或 DQN 学会移动、瞄准、射击、躲避最终能稳定击败规则型 AI。适合有 Python 基础、想从零手写强化学习闭环的开发者。整套代码不依赖任何商业引擎纯 Python Pygame 做可视化训练和推理都在本地跑得动。我见过太多人卡在“环境怎么写”“状态怎么设计”“奖励怎么给”这三个问题上所以这篇会按实际落地顺序把每个环节的参数和坑都摊开讲。2. 环境搭建与坦克大战游戏逻辑实现2.1 为什么选 Pygame 而不是 Gym 封装Gym 的接口确实标准但坦克大战的状态不是固定维度的向量而是网格地图 多实体位置。硬套 Gym 的 Box 空间反而会让状态设计变得别扭。我一般直接用 Pygame 写一个轻量环境类只暴露reset()、step(action)、get_state()三个方法后面接 Q-learning 还是 DQN 都不影响。Pygame 的安装很简单但要注意版本兼容pip install pygame2.5.2 numpy1.26.4提示Pygame 2.5.x 在 Windows 和 macOS 上对 Python 3.10~3.12 支持最好Linux 下如果报SDL相关错误先装libsdl2-dev。环境的核心参数先定下来后面所有逻辑都围绕这几个值展开参数值说明网格尺寸13×13每个格子 40px窗口 520×520动作空间6上、下、左、右、射击、不动最大步数500单局上限防止死循环坦克血量3被击中 3 次死亡子弹速度2 格/帧比坦克移动快保证命中判定2.2 用 Python 类组织游戏实体坦克、子弹、地图块都用独立的类但不要过度继承。我习惯把公共属性放在一个Entity基类里只保留x、y、alive三个字段其余全部在子类里写。import pygame import numpy as np CELL 40 GRID 13 class Tank: def __init__(self, x, y, color, is_playerTrue): self.x x self.y y self.color color self.is_player is_player self.hp 3 self.alive True self.cooldown 0 # 射击冷却帧数 def move(self, action, obstacles): dx, dy 0, 0 if action 0: dy -1 elif action 1: dy 1 elif action 2: dx -1 elif action 3: dx 1 nx, ny self.x dx, self.y dy if 0 nx GRID and 0 ny GRID and (nx, ny) not in obstacles: self.x, self.y nx, ny def shoot(self): if self.cooldown 0: self.cooldown 10 return Bullet(self.x, self.y, self.is_player) return Nonemove里先算目标格再检查边界和障碍物不满足就原地不动。cooldown是射击间隔10 帧大约 0.16 秒防止一帧内连发。Bullet类只需要记录方向和归属每帧更新位置超出地图或命中就置aliveFalse。2.3 状态编码把画面变成智能体能吃的向量这是整个方案里最影响收敛速度的一步。不要直接把 520×520 的 RGB 图像丢给网络那样训练慢且容易过拟合。我一般用三层信息拼成一个固定长度向量第一层13×13 的占用网格0 空地、1 障碍、2 玩家坦克、3 敌方坦克、4 子弹展平后 169 维。第二层玩家坦克的归一化坐标和朝向4 维。第三层敌方坦克相对玩家的 dx、dy 和血量每个敌人 3 维最多 3 个敌人共 9 维。拼起来一共 182 维。这个维度对全连接网络很友好训练时 batch 可以开到 256 不爆内存。def get_state(self): grid np.zeros((GRID, GRID), dtypenp.float32) for ox, oy in self.obstacles: grid[oy, ox] 1 grid[self.player.y, self.player.x] 2 for e in self.enemies: if e.alive: grid[e.y, e.x] 3 for b in self.bullets: if b.alive: grid[b.y, b.x] 4 flat_grid grid.flatten() / 4.0 # 归一化到 0~1 player_info np.array([ self.player.x / GRID, self.player.y / GRID, self.player.hp / 3.0, self.player.cooldown / 10.0 ], dtypenp.float32) enemy_info np.zeros(9, dtypenp.float32) for i, e in enumerate(self.enemies[:3]): if e.alive: enemy_info[i*3] (e.x - self.player.x) / GRID enemy_info[i*31] (e.y - self.player.y) / GRID enemy_info[i*32] e.hp / 3.0 return np.concatenate([flat_grid, player_info, enemy_info])注意归一化一定要做否则坐标值 0~12 和血量 0~3 混在一起网络会偏向数值大的特征。2.4 奖励函数设计别让智能体学会“苟着不动”奖励是强化学习的玄学重灾区。我踩过的坑是只给击杀奖励智能体学会绕圈不打只给生存奖励它学会卡墙角不动。比较稳的组合是击杀敌人10自己被击中-5自己死亡-20每走一步-0.01时间惩罚逼它快点结束子弹命中敌人2比击杀小但反馈更密集射击但没命中-0.1防止乱开枪这套奖励在 500 步上限下大约 300 局就能看到智能体主动追击敌人。如果发现它一直原地转圈先把时间惩罚从 -0.01 调到 -0.05 试试。3. 用 Q-learning 跑通第一个能打的坦克智能体3.1 Q 表还是 Q 网络先看状态维度182 维状态如果离散化每个维度取 3 个值Q 表大小是 3^182根本存不下。所以纯 Q-learning 必须降维。我一般把状态压缩成 6 个关键特征玩家 x、玩家 y、最近敌人 dx、最近敌人 dy、玩家血量、敌人数量。每个特征离散成 5 档Q 表大小 5^6 × 6 ≈ 93750内存完全放得下。def discretize(state): # 只取关键特征做离散化 px int(state[169] * 4) # 玩家 x 归一化后 0~1映射到 0~4 py int(state[170] * 4) edx int((state[173] 1) / 2 * 4) # 敌人 dx 从 -1~1 映射到 0~4 edy int((state[174] 1) / 2 * 4) hp int(state[171] * 4) enemy_cnt int(np.sum(state[16942::3] 0)) return (px, py, edx, edy, hp, enemy_cnt)state[169]是玩家 x 的归一化值state[173]是第一个敌人的 dx。这里只取最近敌人多个敌人时按距离排序取第一个。enemy_cnt统计血量大于 0 的敌人数量。3.2 训练循环epsilon-greedy 和学习率怎么设Q-learning 的核心更新公式就一行但参数设置直接决定能不能收敛import random q_table {} alpha 0.1 # 学习率 gamma 0.95 # 折扣因子 epsilon 1.0 # 初始探索率 epsilon_min 0.05 epsilon_decay 0.995 def choose_action(state_key): if random.random() epsilon: return random.randint(0, 5) if state_key not in q_table: q_table[state_key] np.zeros(6) return int(np.argmax(q_table[state_key])) def update_q(state_key, action, reward, next_key): if state_key not in q_table: q_table[state_key] np.zeros(6) if next_key not in q_table: q_table[next_key] np.zeros(6) best_next np.max(q_table[next_key]) q_table[state_key][action] alpha * (reward gamma * best_next - q_table[state_key][action])alpha0.1是保守值状态离散后噪声大学习率太高会导致 Q 值震荡。gamma0.95让智能体关注未来 20 步左右的回报坦克大战单局通常 100~300 步这个折扣够用。epsilon从 1.0 线性衰减到 0.05衰减率 0.995 意味着大约 600 局后探索率降到最低。训练主循环for episode in range(2000): env TankBattle() state discretize(env.get_state()) total_reward 0 for step in range(500): action choose_action(state) next_state_raw, reward, done env.step(action) next_state discretize(next_state_raw) update_q(state, action, reward, next_state) state next_state total_reward reward if done: break epsilon max(epsilon_min, epsilon * epsilon_decay) if episode % 100 0: print(fEpisode {episode}, Reward: {total_reward:.1f}, Epsilon: {epsilon:.3f})3.3 怎么判断 Q-learning 真的在学不要只看总奖励那个波动太大。我一般看三个指标平均击杀数每 100 局统计一次从 0.2 涨到 1.5 以上说明在进步。平均存活步数如果一直卡在 500 步上限说明智能体在苟检查时间惩罚。Q 表大小稳定在 8000~12000 之间比较健康太小说明状态离散太粗太大说明太细导致泛化差。如果 500 局后击杀数还在 0.3 以下先把epsilon_decay改成 0.99让它探索更久。如果 Q 表超过 50000 还没收敛把离散档位从 5 降到 4。4. 升级到 DQN用神经网络替代 Q 表4.1 什么时候该换 DQNQ-learning 在 6 特征离散化下能跑但有两个硬伤一是状态信息丢失太多敌人子弹位置完全没进状态二是离散化边界太硬玩家在格子边缘时 Q 值跳变。DQN 直接用 182 维连续向量网络自己学特征上限高很多。换 DQN 的时机Q-learning 击杀数稳定在 1.5 以上不再涨或者你想加更多敌人、更复杂地图。如果只是验证想法Q-learning 够用要发论文或做演示DQN 更稳。4.2 网络结构和经验回放的最小实现网络不需要深三层全连接足够import torch import torch.nn as nn import torch.optim as optim from collections import deque import random class QNet(nn.Module): def __init__(self, state_dim182, action_dim6): super().__init__() self.fc nn.Sequential( nn.Linear(state_dim, 256), nn.ReLU(), nn.Linear(256, 256), nn.ReLU(), nn.Linear(256, action_dim) ) def forward(self, x): return self.fc(x) class ReplayBuffer: def __init__(self, capacity10000): self.buffer deque(maxlencapacity) def push(self, s, a, r, s_next, done): self.buffer.append((s, a, r, s_next, done)) def sample(self, batch_size): batch random.sample(self.buffer, batch_size) s, a, r, s_next, done zip(*batch) return (torch.FloatTensor(np.array(s)), torch.LongTensor(a), torch.FloatTensor(r), torch.FloatTensor(np.array(s_next)), torch.FloatTensor(done))ReplayBuffer容量 10000太小会导致样本相关性太强太大会拖慢采样。batch_size设 64 或 128我一般用 128训练更稳。4.3 训练参数和 target network 的更新节奏DQN 有两个网络在线网络和目标网络。目标网络每 200 步同步一次这是稳定训练的关键。online_net QNet() target_net QNet() target_net.load_state_dict(online_net.state_dict()) optimizer optim.Adam(online_net.parameters(), lr1e-3) buffer ReplayBuffer(10000) batch_size 128 gamma 0.99 sync_every 200 step_count 0 def train_step(): global step_count if len(buffer.buffer) batch_size: return s, a, r, s_next, done buffer.sample(batch_size) q_values online_net(s).gather(1, a.unsqueeze(1)).squeeze(1) with torch.no_grad(): next_q target_net(s_next).max(1)[0] target r gamma * next_q * (1 - done) loss nn.MSELoss()(q_values, target) optimizer.zero_grad() loss.backward() optimizer.step() step_count 1 if step_count % sync_every 0: target_net.load_state_dict(online_net.state_dict())gamma0.99比 Q-learning 的 0.95 更看重长期回报因为 DQN 状态更丰富能区分更多场景。lr1e-3是 Adam 的常用值如果 loss 震荡厉害降到 5e-4。sync_every200是经验值太小目标网络跟不上太大训练滞后。注意done要转成 0/1 浮点数1 - done在终止状态时把未来回报截断否则智能体会学到“死亡后还有奖励”的错误逻辑。4.4 从训练日志里看 DQN 有没有翻车DQN 比 Q-learning 更容易出现训练不稳定。我一般盯这几个信号loss 突然飙到 100 以上通常是 target network 没同步或者 buffer 里混入了异常状态。平均 Q 值持续下降奖励设计有问题智能体在“学会不做事”。击杀数先涨后跌过拟合了把lr降到 1e-4 或者加大 buffer 到 50000。如果训练 1000 局后击杀数还在 0.5 以下先检查状态里的敌人信息是不是全 0——很多时候是enemies列表在reset()后没重新填充。5. 避坑与排查坦克大战强化学习里最容易翻车的 5 个点5.1 智能体一直原地转圈击杀数不涨现象训练 500 局后智能体每局都在角落来回移动从不主动靠近敌人。原因时间惩罚太小生存奖励相对太高。智能体发现“活着”比“冒险击杀”更划算。解决把每步惩罚从 -0.01 调到 -0.05同时把击杀奖励从 10 提到 20。如果还不行直接去掉生存奖励只保留击杀和命中奖励。5.2 子弹命中判定偶尔失效现象明明看到子弹穿过敌人但血量没扣。原因子弹和坦克的坐标更新顺序不对。如果先更新子弹位置再检查碰撞子弹可能一帧跳两格直接越过坦克。解决子弹速度设为 1 格/帧或者每帧移动后立即做碰撞检测。我一般把子弹速度降到 1虽然慢一点但判定稳。5.3 Q 表爆炸到几十万条现象训练几千局后Q 表大小超过 100000内存占用飙升训练变慢。原因离散化档位太细或者状态里混入了连续值比如冷却时间没归一化。解决检查discretize函数确保每个特征都映射到固定档位。把 5 档降到 4 档Q 表大小会指数级下降。5.4 DQN 的 loss 变成 NaN现象训练几百步后 loss 突然变成 nan网络输出全为 0。原因奖励值太大或者学习率太高导致梯度爆炸。解决先把奖励缩放到 -1~1 之间击杀 1、死亡 -1、每步 -0.01。然后把lr从 1e-3 降到 1e-4。如果还不行加梯度裁剪torch.nn.utils.clip_grad_norm_(online_net.parameters(), 1.0)。5.5 训练时快时慢同一局结果差异巨大现象同样的模型有时候 10 秒结束有时候跑满 500 步。原因Pygame 的帧率控制没做好clock.tick()在训练时不应该限制帧率。解决训练模式下把clock.tick(60)去掉只在渲染时加。另外把random.seed()和np.random.seed()固定方便复现。6. 让坦克学会“预判射击”一个状态增强的小技巧前面所有状态设计里子弹位置只占了网格的 4 值网络很难从单帧里推断子弹轨迹。我试过一个很管用的增强在状态里额外加 4 维表示“当前敌人是否在玩家正前方 3 格内”和“敌人朝向是否对着玩家”。def get_aim_features(player, enemies): features np.zeros(4, dtypenp.float32) for i, e in enumerate(enemies[:2]): if not e.alive: continue dx e.x - player.x dy e.y - player.y # 玩家正前方 3 格内是否有敌人 if abs(dx) 3 and abs(dy) 3: features[i*2] 1.0 # 敌人是否在玩家射击线上 if dx 0 or dy 0: features[i*21] 1.0 return features把这 4 维拼到原来的 182 维后面变成 186 维。训练 300 局后智能体的射击命中率从 18% 涨到 34%。这个改动很小但效果立竿见影。验证方法也简单开一个测试模式固定敌人位置只让智能体射击统计 100 次射击的命中次数。如果命中率低于 25%说明瞄准特征没起作用检查dx 0 or dy 0这个条件是不是太严格——坦克大战里敌人很少正好在正前方可以放宽到abs(dx) 1 or abs(dy) 1。我自己的习惯是每加一个特征先跑 100 局看击杀数变化涨了保留没涨就删掉。强化学习里特征工程比调参重要得多别急着上大网络先把状态设计到位。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网