基于强化学习的贪吃蛇游戏(三)——基于Q-learning算法的智能体

在完成了贪吃蛇游戏的基本功能后,我们将尝试实现一个能自主学习玩贪吃蛇的AI智能体。本节我们通过实现前文提到的Q-learning算法,进一步直接体会强化学习的实现与训练过程。

log😄😅=💧\log_{😄}😅=💧

状态建模

在Q-learning算法中,核心是维护一个用于表示动作价值的Q-table。在贪吃蛇游戏中,我们可以直观的将状态简化为以下特征。

  • 食物相对于蛇头所处的方位:(上/中/下,左/中/右)。上/中/下与左/中/右分别使用0/1/2表示,共3×3=93 \times 3 = 9种情况。
  • 蛇头的上/下/左/右是否存在边界或身体:(是/否,是/否,是/否,是/否)。是/否分别使用1/0表示,共24=162^4 = 16种情况。
    结合这两种情形作为状态空间,那么状态空间的大小即 9×16=1449 \times 16 = 144。考虑到贪吃蛇的动作仅有上、下、左和右,则动作空间的大小为4。那么我们所需要维护的状态动作对(s,a)(s,a)共有 144×4=576144 \times 4 = 576 对,即,Q-table的大小为(144,4)(144,4)

智能体初始化

我们创建一个agents.py用于存储智能体相关代码。在本节中,我们创建QLearning类完成智能体的训练与交互。
Q-learning算法的初始化主要包括QQ值表和相关学习参数的设置。在QLearning类的构造函数中,我们初始化了以下关键参数:

1
2
3
4
5
6
7
8
class QLearning:
def __init__(self):
self.q_table = np.zeros((144, 4)) # 初始化Q表,状态空间为144,动作空间为4
self.learning_rate = 0.1 # 学习率α
self.gamma = 0.9 # 折扣因子γ
self.epsilon = 0.1 # 探索率ε
self.epsilon_decay = 0.999 # 探索率衰减
self.time_step = 0 # 时间步

其中,QQ值表初始化为全0矩阵,表示智能体在开始时对所有状态动作对的价值估计都是0。学习率控制新知识的接受程度,折扣因子决定未来奖励的重要性,探索率则平衡了探索与利用的关系。

获取状态

当我们获得了蛇头和食物的位置之后,我们需要按设计的状态规则对游戏信息进行抽象化。为了使代码更加模块化,我们在QLearning类中加入一个get_state方法以获取状态。get_state方法首先需要通过坐标相减的方式获取食物相对蛇头的位置。

1
2
3
4
5
6
7
def get_state(self, snake, food, done=False):
'''
获取状态,共(3 ** 2) * (2 ** 4) = 144,即食物相对蛇头的位置是否处于(上/中/下,左/中/右),蛇头(上,下,左,右)是否存在边界或身体。
'''
# 获取食物相对蛇头的位置
vertical_position = np.sign(food.rect.top - snake.body[0].top) + 1 # +1以保证索引从0开始
horizontal_position = np.sign(food.rect.left - snake.body[0].left) + 1

在判断食物的相对方位后,对蛇头四周是否存在边界和身体进行判断。

1
2
3
4
5
6
7
8
9
10
11
12
13
# 判断蛇头四周是否存在边界或身体
state_surround = [0] * 4
for i, direction in enumerate(DIRECTIONS):
left, top = snake.body[0].topleft + np.array(DIRECTIONS[direction]) * UNIT
# 判断是否存在边界
if left < 0 or left > SCREEN_X or top < 0 or top > SCREEN_Y:
exist = True
# 判断是否存在身体
elif (left, top) in [body.topleft for body in snake.body[1:]]:
exist = True
else:
exist = False
state_surround[i] = front_exist

最后,我们调用numpy库中的ravel_multi_index方法,将这几维信息按形状映射到一维索引上,从而对应到Q-table中。

1
2
3
# 将多维状态转换为一维索引
state_index = np.ravel_multi_index((vertical_position, horizontal_position, *state_surround), (3, 3, 2, 2, 2, 2))
return state_index

通过get_state方法,我们将具体的游戏信息映射到了对应的状态索引上,从而可以在Q-table中查询此状态下对应的所有动作价值。

动作选择

在动作选择过程中,我们采用ϵ\epsilon-贪婪策略来平衡探索和利用的关系。具体而言,我们生成一个0-1之间的随机数,用于判断是否进行探索。如果探索,则在动作空间中随机选择一个动作,反之则选取当前状态下QQ值最大的动作。

1
2
3
4
5
6
7
8
9
def choose_action(self, state):
# 生成一个0-1之间的随机数
if np.random.uniform() < self.epsilon:
# 探索:随机选择一个动作
action = np.random.choice(4)
else:
# 利用:选择Q值最大的动作
action = np.argmax(self.q_table[state])
return action

Q值更新

QQ值的更新是算法的核心,遵循Q-learning的更新公式:

Q(s,a)Q(s,a)+α[r+γmaxaQ(s,a)Q(s,a)]Q(s,a) \leftarrow Q(s,a) + \alpha \left[ r + \gamma \max_{a^\prime} Q(s^\prime, a^\prime) - Q(s,a) \right]

注意到当一局游戏进行到最后一步,即蛇死亡的最后一步时,“下一状态” 并不存在。故最后一步的更新公式直接根据即时奖励进行更新:

Q(s,a)Q(s,a)+α[rQ(s,a)]Q(s,a) \leftarrow Q(s,a) + \alpha \left[ r - Q(s,a) \right]

1
2
3
4
5
6
7
8
9
10
11
def learn(self, s, a, r, s_, done):
q_predict = self.q_table[s, a] # 预测的Q值
if done:
# 如果游戏结束,则目标Q值为即时奖励
q_target = r
else:
# 如果游戏未结束,则目标Q值为奖励加上折扣因子乘以下一个状态的最大Q值
q_target = r + self.gamma * np.max(self.q_table[s_])
self.q_table[s, a] += self.learning_rate * (q_target - q_predict) # 更新Q值
self.epsilon = max(self.epsilon * self.epsilon_decay, 0.01) # 更新探索率
self.time_step += 1

至此,我们完成了对QLearning类的定义,从而使其具备基本的参数迭代能力。然而,我们仍需对主程序进行修改,以使Q-learning算法的训练和推理完全嵌入到贪吃蛇游戏中。

基于强化学习的游戏设计

在完成了Q-learning算法的实现和智能体的定义后,我们需要将Q-learning的训练和推理能力完整地嵌入到贪吃蛇游戏中,实现一个基于强化学习的自动玩贪吃蛇的智能体。本节将详细讲解如何设计游戏主程序,使Q-learning算法能够训练智能体并通过训练结果进行推理。
在将Q-learning算法嵌入到贪吃蛇游戏的主程序中,我们需要对主程序进行改造,使其能够支持强化学习智能体的训练和推理。首先,我们需要初始化强化学习智能体,并将键盘控制的开关改为False。

1
2
3
4
5
MODEL = 'QLearning'
KEYBOARD_CONTROL = False
# 初始化强化学习模型
agent = Agent(MODEL)
agent.model.time_step = 0

在游戏的每一帧中,智能体需要实时获取当前的游戏状态,并根据状态选择动作。通过调用get_state方法,智能体将游戏环境(如蛇头位置、食物位置、障碍物位置等)编码为一个唯一的状态表示。随后,利用ϵ\epsilon-贪婪策略,智能体通过choose_action方法决定下一步的动作。在这一阶段,智能体会在探索和利用之间进行权衡:在训练初期,智能体倾向于探索更多的随机动作以获取环境信息;而在训练后期,随着探索率逐渐衰减,智能体会更多地依赖Q-table中存储的策略,选择当前最优动作。

1
2
3
4
5
s = agent.model.get_state(snake, food)
if not KEYBOARD_CONTROL:
a = agent.model.choose_action(s)
# 移动蛇
snake.move(a)

根据Q-learning算法的设计,智能体执行动作后,游戏环境会发生变化。蛇将根据选择的方向移动,同时更新其状态(如位置和长度)。在这个过程中,我们需要根据游戏规则设计合适的即使奖励机制。在本例中,当蛇吃到食物时,给予+10的即时奖励;当蛇撞到墙壁或自身时,给予惩罚性的-10的即时奖励。在许多问题中,往往一开始智能体很难通过随机的策略获得成功的奖励,这时我们可以设计过程中的奖励或惩罚来引导其成功。在本例中,我们根据蛇头与食物之间的曼哈顿距离给予适度的负奖励,以激励蛇向食物靠近的行为,从而加速训练过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
r = 0
# 判断是否吃到食物
if snake.body[0].colliderect(food.rect): # 如果蛇头与食物碰撞
snake.body.append(snake.body[-1].copy()) # 增加蛇的长度
food.set_food(snake) # 生成新的食物
score += 1
r = 10
# 判断是否死亡
elif snake.is_dead():
r = -10
done = True
scores.append(score)
print(f"Episode {episode+1}, Score: {score}")
else:
head_left, head_top = snake.body[0].left, snake.body[0].top
food_left, food_top = food.rect.left, food.rect.top
# 曼哈顿距离,归一化到1后再乘-0.1的系数
r = - 0.1 * (abs(head_left - food_left) + abs(head_top - food_top)) / 22

在完成动作执行和奖励计算后,智能体需要通过learn方法更新Q-table的值。Q-learning的核心在于使用当前状态、动作、奖励以及下一状态,对 Q-table 中对应的状态动作对进行更新。通过这一过程,智能体能够逐渐学习到哪些动作在特定状态下是最优选择,从而优化游戏表现。

1
2
3
4
5
6
# 更新状态  
s_ = agent.model.get_state(snake, food, done)
agent.model.learn(s, a, r, s_, done)
if done:
break
s = s_

当游戏结束时(如蛇撞到墙壁或自身),智能体会输出当前回合的得分,并将游戏环境重置,开始下一轮训练。通过不断循环这一过程,智能体能够在多次训练中逐步提高表现。最终,智能体可以在没有人工干预的情况下高效地玩贪吃蛇游戏。
图1展示了Q-learning算法得分随轮次变化的折线图。横轴表示训练的轮次,纵轴表示每轮游戏的得分。从图中可以观察到,随着训练轮次的增加,智能体的平均得分呈现出逐渐上升的趋势,表明Q-learning算法在不断优化其策略,逐步学会如何在游戏中表现得更好。
在训练初期(前200轮),智能体的得分较低且波动较大,说明智能体对环境的探索较多,策略还处于随机尝试阶段。随着训练的进行,智能体逐步积累经验,其得分逐渐提高并趋于稳定。在中后期(约800轮之后),得分的波动性依然存在,但整体水平明显提升,最高得分接近35分。

Q-learning算法得分随轮次变化折线图

图1 Q-learning算法得分随轮次变化折线图


基于强化学习的贪吃蛇游戏(三)——基于Q-learning算法的智能体
http://dufolk.github.io/2024/12/19/snake-2/
作者
Dufolk
发布于
2024年12月19日
许可协议