贪吃蛇 RL 项目笔记
这个项目用的算法叫 DQN

游戏本体可以说相当简单,这里制作了一个简单的游戏
项目目录为:
大概是怎么运作的
游戏环境 ──状态 s──► Agent(神经网络)──动作 a──► 游戏环境
▲ │
└──────────────── 奖励 r + 新状态 s' ◄──────────────┘
每一帧,Agent 看一眼当前局面(状态 s),决定往哪走(动作 a),
游戏告诉它结果怎样(奖励 r)以及现在的新局面(s')。
这个循环不断重复,Agent 慢慢摸索出哪些决策能得高分。
训练早期 Agent 完全是乱走的,撞墙、绕圈、无视食物------这很正常,
它需要先积累足够多的"失败经验"才能开始学到东西。
文件结构
First Game/
├── The Game/
│ └── snake.py # 原版游戏,给人玩的
├── RL/
│ ├── env.py # 游戏逻辑,剥掉了渲染部分
│ ├── model.py # 神经网络
│ ├── agent.py # DQN 的核心:决策、记忆、训练
│ ├── train.py # 跑训练
│ └── play.py # 看训练好的 AI 玩
├── RL_GUIDE.md
└── requirements.txt
env.py --- 把游戏变成训练接口
原版 snake.py 是给人玩的,依赖 curses 画面、等键盘输入。
训练 AI 的时候每秒要跑几千步,不需要也不能有这些东西。
所以要把游戏逻辑单独抽出来,对外只暴露三个方法:
python
env = SnakeEnv(width=20, height=20)
state = env.reset() # 开新局,返回初始状态
state, reward, done = env.step(action) # 走一步
env.render() # 调试用,打印到终端
这个接口设计参考了 OpenAI Gym 的规范,以后如果想换别的游戏,
只要 env 的接口一样,agent 和 train 的代码完全不用动。
状态怎么表示
状态是 Agent 的"感知",设计得好不好直接决定学习难度。
这里用 11 个 0/1 的数字描述当前局面:
危险感知(相对于当前朝向):
[0] 正前方有没有墙或蛇身
[1] 右边有没有
[2] 左边有没有
当前朝向(one-hot):
[3] 朝上
[4] 朝下
[5] 朝左
[6] 朝右
食物方位(相对于蛇头):
[7] 食物在左
[8] 食物在右
[9] 食物在上
[10] 食物在下
为什么不直接把整个棋盘喂给网络?
可以,但那样输入维度是 20×20=400,网络要大很多,训练也慢很多。
11 维这个设计已经包含了做决策所需的全部关键信息,够用了。
奖励怎么给
吃到食物 +10
撞墙/撞自己 -10
每步存活 0(也可以给 -0.01,逼它快点找食物)
奖励设计是 RL 里最需要动脑子的部分。
给太密集(每步都有奖励)容易让 Agent 学到奇怪的捷径;
给太稀疏(只有吃到食物才有奖励)前期很难学,因为随机走很难碰到食物。
这套设计是个比较平衡的起点。
model.py --- 网络结构
用最简单的全连接网络(MLP)就够了:
输入:11 维状态向量
↓
全连接层 256,ReLU
↓
全连接层 256,ReLU
↓
输出:3 个 Q 值(对应 3 个动作)
输出是 3 而不是 4,因为动作用的是相对方向 :直走、左转、右转。
蛇本来就不能掉头,用相对方向比绝对方向(上下左右)少一个无效动作,学起来更干净。
agent.py --- DQN 的核心
Q 值是什么
Q(s, a) 是一个预测值,表示"在状态 s 下选动作 a,从现在开始能拿到多少总奖励"。
网络学会准确预测 Q 值之后,每步只要选 Q 值最大的动作就行了。
经验回放
Agent 不是走一步就立刻学一步,而是把每步的经历存起来:
python
memory.append((state, action, reward, next_state, done))
训练时从这个"记忆池"里随机抽一批出来学。
这样做有两个好处:
- 同一段经历可以被反复学习,数据利用率高
- 随机采样打破了时序相关性,梯度更新更稳定
记忆池满了就覆盖最老的记录,通常设 10 万条左右。
ε-greedy:探索与利用的平衡
训练初期 Agent 什么都不懂,如果一直选"当前最优"动作,
它会很快陷入一个局部策略出不来。所以需要随机探索。
以概率 ε → 随机选一个动作(探索)
以概率 1-ε → 选 Q 值最大的动作(利用)
ε 从 1.0 开始(完全随机),随着训练进行慢慢降到 0.01(基本不随机)。
前期多探索积累经验,后期多利用已学到的知识。
目标网络
DQN 有个经典的稳定性问题:训练目标(目标 Q 值)和被训练的网络是同一个,
每次更新网络,目标也跟着变,像追一个移动的靶子,容易震荡。
解决方法是用两个网络:
- 主网络:每步都在更新
- 目标网络:每隔 N 步从主网络复制一次,其余时间冻结
目标 Q 值用目标网络算,这样训练目标在一段时间内是固定的,稳多了。
Bellman 方程(训练公式)
目标 Q = r + γ × max_a'[ Q_target(s', a') ]
损失 = MSE( Q_main(s, a), 目标 Q )
- r 是这步拿到的即时奖励
- γ(gamma)是折扣因子,通常 0.9,意思是"未来的奖励没有现在的值钱"
max_a'是在下一个状态里,目标网络认为最好的动作的 Q 值
用梯度下降最小化这个损失,主网络的预测就会越来越准。
train.py --- 训练主循环
逻辑很直白:
python
for episode in range(总局数):
state = env.reset()
while True:
action = agent.choose_action(state) # ε-greedy
next_state, reward, done = env.step(action)
agent.remember(state, action, reward, next_state, done)
agent.train_step() # 从记忆池采样,跑一次反向传播
state = next_state
if done:
break
agent.decay_epsilon()
# 每 50 局打印一次平均分,每 200 局保存一次模型
训练过程中要记录每局的分数,画成曲线看趋势。
分数不是单调上升的,会有很大波动,看的是整体趋势有没有在涨。
play.py --- 看 AI 玩
训练完之后加载保存的权重,关掉随机探索(ε=0),
用 env.render() 把每一帧打印到终端,加个 time.sleep(0.1) 控制速度,
就能看到 AI 实时玩游戏了。
依赖
torch>=2.0
numpy
matplotlib
bash
conda activate TouhouSSL
pip install torch numpy matplotlib
PyTorch 如果有 GPU 会自动用,没有也没关系,这个规模 CPU 跑得动。
训练大概会经历这几个阶段
0~100 局 :完全在乱走,基本上一出生就撞墙,分数接近 0。
这是正常的,记忆池还没积累够,网络在学随机噪声。
100~500 局 :开始有点方向感,会朝食物走,但经常绕进死角出不来。
ε 还比较高,还在大量探索。
500~2000 局 :能稳定吃到食物了,蛇变长之后偶尔会撞自己。
这个阶段进步最明显,曲线涨得比较快。
2000 局以后 :趋于稳定,能处理大多数情况,但极端情况(蛇很长时)还是会出问题。
DQN 在这类问题上有天花板,想突破需要换更复杂的算法(PPO、A3C 等)。
超参数参考
python
LEARNING_RATE = 0.001
GAMMA = 0.9
EPSILON_START = 1.0
EPSILON_MIN = 0.01
EPSILON_DECAY = 0.995 # 每局结束后乘这个数
MEMORY_SIZE = 100_000
BATCH_SIZE = 1000
TARGET_UPDATE = 100 # 每隔多少步同步目标网络
这些不是最优解,只是一个能跑起来的起点。
调参本身也是学习的一部分,改一个参数跑一遍,看曲线有什么变化,
比看任何教程都学得快。
动手顺序
先写 env.py,用 env.render() 手动走几步确认逻辑没问题,
再写 model.py 和 agent.py,最后接 train.py。
不要一上来就想把所有东西写完再跑,每写一个文件就测一下,
出问题的时候范围小,好排查。
