强化学习实战3——自定义环境的搭建Q-LEARNING

课程13:编写强化学习环境_哔哩哔哩_bilibili

BL3能为我们解决很多问题,但是其提供的环境确实有限,如果你想训练Agent玩吃豆人,就需要自行配置环境了。

游戏定义

捕食者游戏:

类似吃豆人,有三个对象,玩家、食物、敌人。在网格环境中要求躲避敌人的追捕,吃到食物。

导入依赖库

python 复制代码
#将学习环境所需要的依赖库导入
import numpy as np
import cv2
from PIL import Image
import time
import pickle #对象到文件的处理
import matplotlib.pyplot as plt
from matplotlib import style
style.use('ggplot')

环境参数 训练参数设置

python 复制代码
#环境参数设定
SIZE=10 #3个对象在10*10的范围内
EPISODES=30000 #智能体玩的游戏轮数
SHOW_EVERY=3000 #每3000局展示一次游玩过程

#奖励与惩罚
FOOD_REWARD=25#吃食物的奖励
ENEMY_PENALITY=300 #被敌人抓住的惩罚
MOVE_PENALITY=1 #移动惩罚

#环境计算参数
epsilon=0.6 #在强化学习时抽取随机动作的概率 40%使用最大价值期望动作
EPS_DECAY=0.9998 #每玩一局游戏就让随机动作概率乘以这个数,到最后基本就定性了
DISCOUNT=0.95 #折扣回报 未来奖励的折扣
LEARNING_RATE=0.1 #学习率 步长

#q_table_file = "q_table_save.pkl" 
q_table = None
d = {1:(255,0,0), #蓝色------玩家
     2:(0,255,0), #绿色------食物
     3:(0,0,255)} #红色------敌人
PLAYER_N=1
FOOD_N=2
ENEMY_N=3

对象类的设置

初始化函数与重写函数

首先必要的init函数,初始化对象的位置。

然后编写打印功能函数,输出自身位置。

python 复制代码
#为三个对象创建类
class Cube:
    def __init__(self):#初始位置
        self.x=np.random.randint(0,SIZE)
        self.y=np.random.randint(0,SIZE)

    def __str__(self): #打印当前位置
        return f'{self.x},{self.y}'
python 复制代码
player=Cube()
print(player) #当使用 print() 函数打印一个对象时,Python 会自动调用该对象的 __str__ 方法

再添加一个__sub__函数,重写减法,标志两个对象的欧氏距离。

python 复制代码
def __sub__(self,other):#这个类的另一个实体
        return (self.x-other.x,self.y-other.y)

当你使用 print() 函数打印一个对象时,Python 会自动调用该对象的 __str__ 方法

当你使用A-B时,会自动调用对象的__sub__方法。

编写移动函数

接下来编写移动函数move:

如果没有给定xy的值,就在-1到1之间随机给定一个xy坐标,让对象移动。然后要检查边界,如果超过棋盘SIZE-1,就约束在边界。

python 复制代码
def move(self,x=False,y=False):
        if not x:#如果x没有给值
            self.x += np.random.randint(-1,2)
        else:
            self.x += x

        if not y:#如果y没有给值
            self.y += np.random.randint(-1,2)
        else:
            self.y += y

        #考虑边界
        if self.x<0:
            self.x=0
        elif self.x>=SIZE:
            self.x=SIZE-1
        if self.y<0:
            self.y=0
        elif self.y>=SIZE:
            self.y=SIZE-1
            

编写动作函数(动作函数基于移动):

设定右上 左上 右下 左下四个动作(按理来说应该还要有固定不动)【之后我们会扩展到8个动作】

python 复制代码
    def action(self,choise):
        if choise == 0:
            self.move(x=1,y=1)
        elif choise == 1:
            self.move(x=-1,y=1)
        elif choise == 2:
            self.move(x=1,y=-1)
        elif choise == 3:
            self.move(x=-1,y=-1)

完整的对象类函数

python 复制代码
# 智能体的类,有其 位置信息 和 动作函数
class Cube:
    def __init__(self): # 随机初始化位置坐标
        self.x = np.random.randint(0, SIZE-1)
        self.y = np.random.randint(0, SIZE-1)
    def __str__(self):
        return f'{self.x},{self.y}'
    def __sub__(self, other):
        return (self.x-other.x,self.y- other.y)
    def action(self,choise):
        if choise == 0:
            self.move(x=1,y=1)
        elif choise == 1:
            self.move(x=-1, y=1)
        elif choise == 2:
            self.move(x=1, y=-1)
        elif choise == 3:
            self.move(x=-1, y=-1)
    def move(self,x=False, y=False):
        if not x:
            self.x += np.random.randint(-1, 2)
        else:
            self.x += x
        if not y:
            self.y += np.random.randint(-1, 2)
        else:
            self.y += y

        if self.x< 0:
            self.x = 0
        if self.x> SIZE -1:
            self.x = SIZE-1
        if self.y< 0:
            self.y = 0
        if self.y> SIZE -1:
            self.y = SIZE-1

初始化Q_table

我们之前定义q_table是None

python 复制代码
q_table = None

第一次训练我们确实没有q_table,但是之后的训练可以居于此前的q_table继续训练,因此这里要做个判断,如果没有q_table就初始化,如果已经有了q_table就加载进来。

加载的代码没什么好说的,就是with open...

我们看一下如何初始化q_table:

python 复制代码
#q_table初始化 整个状态空间有19^4个状态 存储玩家和食物、玩家和敌人的距离作为状态,二者距离差在-9-9之间,有19*19个状态,三个对象有361*361个对象
#内部存储当前最佳的动作

# 初始化Q表格
if q_table is None:   # 如果没有实现提供,就随机初始化一个Q表格
    q_table = {}
    for x1 in range(-SIZE+1,SIZE):
        for y1 in range(-SIZE + 1, SIZE):
            for x2 in range(-SIZE + 1, SIZE):
                for y2 in range(-SIZE + 1, SIZE):
                    q_table[((x1,y1),(x2,y2))] = [np.random.randint(-5,0) for i in range(4)]
    #这是值,一个包含 4 个元素的列表。
    #np.random.uniform(-5,0):给这 4 个动作随机赋一个负数的初始价值(比如 -2.3, -1.5, -4.0, -0.5)。
    #注意:这里存的是价值,不是概率。如果想看概率,需要额外的数学运算(如 Softmax),但 Q-Learning 通常直接用 argmax 拿最大值的索引来作为动作。
else:                # 提供了,就使用提供的Q表格
    with open(q_table,'rb') as f:
        q_table= pickle.load(f)
                    

Q_table是存储所有离散状态的表格,在preadtor游戏中:

状态的定义

在强化学习中,状态(State) 是智能体用来做决策的所有信息的集合。

  1. 相对位置的重要性

    在这个迷宫/吃豆人游戏中,智能体(玩家)并不需要知道它在地图上的绝对坐标(比如 x=5, y=5)。

    • 它真正关心的是
      • 食物在哪儿?(相对于我,食物在左上、右下还是正前方?)
      • 敌人在哪儿?(相对于我,敌人在追我吗?)
    • 结论 :状态应该是相对位置,而不是绝对位置。
  2. 状态向量的构成

    代码中定义的状态由两个相对向量组成:

    • (x1, y1):玩家与食物的相对位置差(player - food)。
    • (x2, y2):玩家与敌人的相对位置差(player - enemy)。
  3. 状态数量的计算

    • SIZE = 10,坐标范围是 09
    • 相对位置差的范围:最小是 -9(我在9,目标在0),最大是 9(我在0,目标在9)。所以每个坐标轴上有 19 个可能的值(从 -9 到 9)。
    • 总状态数
      • 玩家-食物:19 (x轴) × 19 (y轴) = 361 种可能。
      • 玩家-敌人:19 (x轴) × 19 (y轴) = 361 种可能。
      • 总计:361×361=130,321 种状态。
    • 这就是代码中 for 循环嵌套 4 层的原因,它在穷举所有可能的相对位置组合。

我们用一个二维数组存储 q_table[((x1,y1),(x2,y2))],第一组是玩家与食物的坐标差,第二组是玩家与敌人的坐标差。

然后做初始化:

python 复制代码
[np.random.randint(-5,0) for i in range(4)]

这是值,一个包含 4 个元素的列表。

相对于给这 4 个动作随机赋一个负数的初始价值(比如 -2.3, -1.5, -4.0, -0.5),代表当前状态下,向四个方向走的预估价值。

之后会通过更新q_table更新这些Q值。一般来说,智能体会选择当前收益最高的动作。我们可以用argmax函数获取"最高价值对应的索引"

智能体的动作选择

我们首先捋一下如何写训练过程:

首先明确大架构:一共训练EPISODES个批次,限制每个批次训练200步,是明显的双循环。

然后更新q_table是更新其四个动作的q,计算q需要用到当前的reward和预估的q,因此需要在每步和每批次都设计一个变量存储reward。

python 复制代码
epidoe_reward=[]
for episode in range(EPISODES):
    player=Cube()
    food=Cube()
    enemy=Cube()
    
    episode_reward=0
    for i in range(200):
        #TODO 每步的训练流程

接下来我们看每一步应该执行什么?

想象你在过马路:

观察环境-看看有没有来车

选择动作-思考如何过马路

执行动作-过马路

计算奖励-刚刚差点被创飞了,给的奖励低一些,刚刚欻的一下过去了,给定奖励高一些

更新策略-根据这次过马路的经验优化下次

观察环境-看看有没有来车

我们定义obs是环境,环境要包括player到food,player到enemy的状态。

python 复制代码
#拿到环境状态值
obs=(player-food,player-enemy)#obs的状态 类型要和定义的q_table的状态一致

这个状态类型要和q_table的状态要一致,因为选择动作要根据q_table的状态选择Q最大的动作。

选择动作-思考如何过马路

我们前面认为直接选择Q最大的动作即可,但如果每次都按照Q最大的动作做,就会进入局部最优解,因此我们设计了ε-greedy算法,也就是(1-ε)%的概率选择Q最大的动作,但有ε的概率选择其他动作。

python 复制代码
#ε-Greedy
if np.random.random()>epsilon:
    #Q-Learning就是在Q表中找到对应状态的最佳动作
    action=np.argmax(q_table[obs])#argmax就是取最大值的索引(0-3) 
else:
    action = np.random.randint(0,4)#左闭右开

具体如何选择最大Q的动作呢?其实就是查表,q_table存储了所有的状态,每个状态都有一个四维数组,存储着当前状态下采取各个动作预期的Q值,那么用argmax就能取出最大Q值的动作对应的下标,一会交给player的action函数执行即可。

执行动作-过马路

python 复制代码
#执行动作
player.action(action)

计算奖励-刚刚差点被创飞了,给的奖励低一些,刚刚欻的一下过去了,给定奖励高一些

当player与food重合时,就给FOOD_REWARD的奖励,

当player与enemy重合时,就给-ENEMY_PENALITY的惩罚,

其他情况都是移动,给予-MOVE_PENALITY的惩罚。

python 复制代码
#计算奖励
if player.x==food.x and player.y==food.y:
    reward=FOOD_REWARD
elif player.x==enemy.x and player.y == enemy.y:
    reward = -ENEMY_PENALITY
else:
    reward= -MOVE_PENALITY

print(f'reward:{reward}')

更新策略-根据这次过马路的经验优化下次

我们先考虑如何更新,然后再看需要得到哪些信息:

这是Q-Learning 的更新公式,将其转换为代码如下:

python 复制代码
new_q=(1-LEARNING_RATE)*current_q + LEARNING_RATE*(reward + DISCOUNT*max_future_q)

这个公式本质上是在做一个加权平均

  • (1 - 学习率) * 旧值:保留一部分旧的记忆。
  • 学习率 * (即时奖励 + 预估的未来价值):加入新的经验。

我们要做的就是计算出这个new_q,然后把new_q存入到q_table中。

各参数获取

**【LEARNING_RATE】**是在参数设定一节就设好的。

python 复制代码
LEARNING_RATE=0.1 #学习率 步长

**【current_q】**是执行当前动作前的q值,可以根据当前的状态和选择的动作通过查表q_table获取。

python 复制代码
#得到当前的q值
current_q=q_table[obs][action]#从 Q 表中取出:在当前状态 obs 下,执行动作 action 对应的 Q 值

【reward】 上一步得到了。
**【DISCOUNT】**也是在参数设定一节就设好的。

python 复制代码
DISCOUNT=0.95 #折扣回报 未来奖励的折扣

**【max_future_q】**就比较麻烦了,要计算q就需要查表,但是这是执行完当前动作后的q表,因此要获取执行完当前动作后的状态[new_obs]才能查表获取(这里不用[new_obs][new_action]是因为我们直接取最大Q值)。

那么我们先获取new_obs,状态的定义是什么?是不是还是player与food和enemy的坐标差。

那么执行完当前动作后,他们的坐标差是什么?

是不是直接获取就好了,因为我们已经执行了动作,那当前的状态就是new_obs:

python 复制代码
#更新表格需要 新obs和权衡后的q值
#得到新的状态
new_obs=(player-food,player-enemy)

然后直接找这个状态下最大的Q即可:

python 复制代码
max_future_q=np.max(q_table[new_obs])

现在就能用这些参数更新出new_q了!

python 复制代码
        #更新q_table
        #得到当前的q值
        current_q=q_table[obs][action]#从 Q 表中取出:在当前状态 obs 下,执行动作 action 对应的 Q 值
        print(f'current_q:{current_q}')

        #更新表格需要 新obs和权衡后的q值
        #得到新的状态
        new_obs=(player-food,player-enemy)
        print(f'new_obs{new_obs}')

        max_future_q=np.max(q_table[new_obs])
        print(f'max_future_q:{max_future_q}')
        
        new_q=(1-LEARNING_RATE)*current_q + LEARNING_RATE*(reward + DISCOUNT*max_future_q)
        print(f'new_q:{new_q}')

再考虑一下边界,如果吃到食物时,是不是就是真实的奖励Q,无需预估,直接给FOOD_REAWRD即可。

python 复制代码
if reward==FOOD_REWARD:#如果吃到了食物,就用实际奖励更新
    new_q=FOOD_REWARD
else:
    new_q=(1-LEARNING_RATE)*current_q + LEARNING_RATE*(reward + DISCOUNT*max_future_q)
    print(f'new_q:{new_q}')

最后把new_q存进q_table:

python 复制代码
q_table[obs][action]=new_q

然后别忘了把单步奖励reward加到单轮总奖励episode_reward中

python 复制代码
#加上这一步的奖励
episode_reward+=reward

最后再考虑一下退出本批的情况,一种是吃到食物,一种是被enemy击杀,因此要加一句

python 复制代码
if reward==FOOD_REWARD or reward==-ENEMY_PENALITY:
    break #如果吃到食物或被杀死就结束这一轮

在内层循环外部,别忘了把每一轮的奖励append进各批次列表中,方便展示。

然后减小随机率,保证后期收敛。

python 复制代码
#轮批次处理
episode_rewards.append(episode_reward)
epsilon*=EPS_DECAY

完整代码:

python 复制代码
#建立实例-设计超时步数-初始化环境-得到环境状态-选择动作(e-greedy)-执行动作-计算奖励-
#得到new_q(计算当前q值、计算新状态、计算未来最大q)【用q new_obs max_future_q reward】更新-
#更新q_table[obs][action]=new_q(这里是当前obs,new_obs是为了估计max_future_q)的
episode_rewards=[]
for episode in range(EPISODES):
    player=Cube()
    food=Cube()
    enemy=Cube()

    episode_reward=0
    for i in range(200):#超过200步结束
        #拿到环境状态值
        obs=(player-food,player-enemy)#obs的状态 类型要和定义的q_table的状态一致
        #ε-Greedy
        if np.random.random()>epsilon:
            #Q-Learning就是在Q表中找到对应状态的最佳动作
            action=np.argmax(q_table[obs])#argmax就是取最大值的索引(0-3) 
        else:
            action = np.random.randint(0,4)#左闭右开

        #执行动作
        player.action(action)

        #计算奖励
        if player.x==food.x and player.y==food.y:
            reward=FOOD_REWARD
        elif player.x==enemy.x and player.y == enemy.y:
            reward = -ENEMY_PENALITY
        else:
            reward= -MOVE_PENALITY

        #print(f'reward:{reward}')

        #更新q_table
        #得到当前的q值
        current_q=q_table[obs][action]#从 Q 表中取出:在当前状态 obs 下,执行动作 action 对应的 Q 值
        #print(f'current_q:{current_q}')

        #更新表格需要 新obs和权衡后的q值
        #得到新的状态
        new_obs=(player-food,player-enemy)
        #print(f'new_obs{new_obs}')

        max_future_q=np.max(q_table[new_obs])
        #print(f'max_future_q:{max_future_q}')
        if reward==FOOD_REWARD:#如果吃到了食物,就用实际奖励更新
            new_q=FOOD_REWARD
        else:
            new_q=(1-LEARNING_RATE)*current_q + LEARNING_RATE*(reward + DISCOUNT*max_future_q)
        #print(f'new_q:{new_q}')

        q_table[obs][action]=new_q
        
        #加上这一步的奖励
        episode_reward+=reward

        if reward==FOOD_REWARD or reward==-ENEMY_PENALITY:
            break #如果吃到食物或被杀死就结束这一轮


    #轮批次处理
    episode_rewards.append(episode_reward)
    epsilon*=EPS_DECAY

训练展示

python 复制代码
# 图像显示
        if show:
            env = np.zeros((SIZE,SIZE,3),dtype= np.uint8)
            env[food.x][food.y] = d[FOOD_N]
            env[player.x][player.y] = d[PLAYER_N]
            env[enemy.x][enemy.y] = d[ENEMY_N]
            img = Image.fromarray(env,'RGB')
            img = img.resize((800,800))
            cv2.imshow('',np.array(img))
            if reward == FOOD_REWARD or reward == -ENEMY_PENALITY:
                if cv2.waitKey(500) & 0xFF == ord('q'):
                    break
            else:
                if cv2.waitKey(1) & 0xFF == ord('q'):
                    break

这段代码的主要功能是将当前的游戏状态(数据)渲染成一张可视化的图片并显示出来,用于让你直观地看到智能体(Player)是如何在环境中移动的。

🎨 1. 创建画布

复制代码
env = np.zeros((SIZE,SIZE,3), dtype=np.uint8)
  • 含义:创建一个全黑的背景图(画布)。
  • 细节
    • (SIZE, SIZE, 3):表示图像的高、宽和颜色通道(RGB)。SIZE 是游戏地图的大小(比如 10x10)。
    • np.uint8:数据类型是 0-255 的整数,这是图像的标准格式。
    • 初始全是 0,代表黑色背景。

🖌️ 2. 绘制角色(上色)

复制代码
env[food.x][food.y] = d[FOOD_N]
env[player.x][player.y] = d[PLAYER_N]
env[enemy.x][enemy.y] = d[ENEMY_N]
  • 含义:在画布的特定坐标点上涂上特定的颜色。
  • 细节
    • d 是一个字典,里面存储了定义好的颜色(RGB 元组)。例如:FOOD_N 可能对应绿色,PLAYER_N 对应蓝色。
    • 这里直接把对应坐标的像素值改成了定义好的颜色。
    • 注意:这里有一个潜在的索引顺序问题,通常 numpy 数组是 [行, 列][y, x],但代码写的是 [x][y]。如果画面显示方向不对,可能需要调整这里。

🖼️ 3. 转换与放大图像

复制代码
img = Image.fromarray(env, 'RGB')
img = img.resize((800, 800))
  • 含义:将 numpy 数组转换为图片对象,并放大以便人眼观察。
  • 细节
    • Image.fromarray:把数字矩阵变成图片。
    • resize((800, 800)):因为原始地图可能很小(比如 10x10 像素),在屏幕上根本看不清。这里把它强行放大到 800x800 像素,每个格子会变得很大,方便调试和观看。

🖥️ 4. 显示图像

复制代码
cv2.imshow('', np.array(img))
  • 含义:弹出一个窗口显示这张处理好的图片。
  • 细节:OpenCV 需要 numpy 数组格式,所以又把 PIL 图片转回了 numpy 数组。

⏯️ 5. 控制播放速度(关键逻辑)

复制代码
if reward == FOOD_REWARD or reward == -ENEMY_PENALITY:
    if cv2.waitKey(500) & 0xFF == ord('q'):
        break
else:
    if cv2.waitKey(1) & 0xFF == ord('q'):
        break

这段逻辑控制动画的流畅度和暂停:

  • cv2.waitKey(毫秒数)

    • 这是程序的"暂停"指令。它会让程序停下来等待键盘输入,单位是毫秒。
    • 正常移动时 (else)waitKey(1) 表示只暂停 1 毫秒。这意味着动画会跑得飞快,像看视频一样。
    • 游戏结束时 (if)waitKey(500) 表示暂停 500 毫秒(0.5秒)。这是为了让你能看清最后的结果(是吃到了食物,还是撞到了敌人),否则画面一闪而过,你根本不知道发生了什么。
  • & 0xFF == ord('q')

    • 这是一个检测按键的操作。
    • 意思是:如果在等待期间,你按下了键盘上的 'q' 键,就执行 break,跳出循环,关闭程序。

但是每一轮都看就看不完了,有30000轮呢,于是我们考虑一开始设定的SHOW_EVERY参数,只展示每SHOW_EVERY的训练过程:

python 复制代码
# 每隔一段时间设定show为True,显示图像
    if episode % SHOW_EVERY == 0:
        print('episode ',episode,'  epsilon:',epsilon)
        print('mean_reward:',np.mean(episode_rewards[-SHOW_EVERY:]))
        show = True
    else:
        show = False

完整代码:

python 复制代码
#建立实例-设计超时步数-初始化环境-得到环境状态-选择动作(e-greedy)-执行动作-计算奖励-
#得到new_q(计算当前q值、计算新状态、计算未来最大q)【用q new_obs max_future_q reward】更新-
#更新q_table[obs][action]=new_q(这里是当前obs,new_obs是为了估计max_future_q)的
episode_rewards=[]
for episode in range(EPISODES):
    player=Cube()
    food=Cube()
    enemy=Cube()

    # 每隔一段时间设定show为True,显示图像
    if episode % SHOW_EVERY == 0:
        print('episode ',episode,'  epsilon:',epsilon)
        print('mean_reward:',np.mean(episode_rewards[-SHOW_EVERY:]))
        show = True
    else:
        show = False

    episode_reward=0
    for i in range(200):#超过200步结束
        #拿到环境状态值
        obs=(player-food,player-enemy)#obs的状态 类型要和定义的q_table的状态一致
        #ε-Greedy
        if np.random.random()>epsilon:
            #Q-Learning就是在Q表中找到对应状态的最佳动作
            action=np.argmax(q_table[obs])#argmax就是取最大值的索引(0-3) 
        else:
            action = np.random.randint(0,4)#左闭右开

        #print(player)
        #print(obs)
        #print(action)
        #执行动作
        player.action(action)
        #food.action
        #print('after action:')
        #print(player)

        #计算奖励
        if player.x==food.x and player.y==food.y:
            reward=FOOD_REWARD
        elif player.x==enemy.x and player.y == enemy.y:
            reward = -ENEMY_PENALITY
        else:
            reward= -MOVE_PENALITY

        #print(f'reward:{reward}')

        #更新q_table
        #得到当前的q值
        current_q=q_table[obs][action]#从 Q 表中取出:在当前状态 obs 下,执行动作 action 对应的 Q 值
        #print(f'current_q:{current_q}')

        #更新表格需要 新obs和权衡后的q值
        #得到新的状态
        new_obs=(player-food,player-enemy)
        #print(f'new_obs{new_obs}')

        max_future_q=np.max(q_table[new_obs])
        #print(f'max_future_q:{max_future_q}')
        #逻辑:智能体当前可能因为 ϵ -greedy 探索策略而做了一个"傻事"(比如撞墙),或者做了一个次优动作。
        #目标:但我们更新 Q 值时,不应该参考那个"傻事",而是要参考如果我们在那个新位置(s')重新开始,最聪明的做法能拿多少分。
        #结果:这使得 Q-Learning 的更新目标(Target)总是朝着"最好"的方向修正,最终收敛到最优解。
        if reward==FOOD_REWARD:#如果吃到了食物,就用实际奖励更新
            new_q=FOOD_REWARD
        else:
            new_q=(1-LEARNING_RATE)*current_q + LEARNING_RATE*(reward + DISCOUNT*max_future_q)
        #print(f'new_q:{new_q}')

        q_table[obs][action]=new_q


        # 图像显示
        if show:
            env = np.zeros((SIZE,SIZE,3),dtype= np.uint8)
            env[food.x][food.y] = d[FOOD_N]
            env[player.x][player.y] = d[PLAYER_N]
            env[enemy.x][enemy.y] = d[ENEMY_N]
            img = Image.fromarray(env,'RGB')
            img = img.resize((800,800))
            cv2.imshow('',np.array(img))
            if reward == FOOD_REWARD or reward == -ENEMY_PENALITY:
                if cv2.waitKey(500) & 0xFF == ord('q'):
                    break
            else:
                if cv2.waitKey(1) & 0xFF == ord('q'):
                    break
        
        #加上这一步的奖励
        episode_reward+=reward

        if reward==FOOD_REWARD or reward==-ENEMY_PENALITY:
            break #如果吃到食物或被杀死就结束这一轮


    #轮批次处理
    episode_rewards.append(episode_reward)
    epsilon*=EPS_DECAY
        

训练结果解析

从头执行到这里,可以看到如下输出:

可以发现,收敛非常快,从-150到-12,在动绘中也能看出后期几乎是瞬间就吃到了绿点(FOOD)

统计量输出

我们希望得到各个批次的奖励是如何变化的,我们不希望每一个批次都单独输出,而是平滑一些,取相邻几次和自己的平均一起输出,我们可以用卷积函数:

卷积就是对每k个数取mean:

python 复制代码
moving_avg = np.convolve(episode_rewards, np.ones((SHOW_EVERY,))/SHOW_EVERY,mode='valid')
plt.plot([i for i in range(len(moving_avg))], moving_avg)
plt.xlabel('episode #')
plt.ylabel(f'mean{SHOW_EVERY} reward')
plt.show()
  • 原理

    • np.ones((SHOW_EVERY,))/SHOW_EVERY 创建了一个长度为 SHOW_EVERY 的窗口,里面每个值都是 1/N 。
    • 当这个窗口在数据上"滑过"时,每滑一步,它就会把覆盖住的那几个数加起来乘以 1/N 。
    • 这本质上就是在算平均值

可以看出,整个过程非常稳定!

保存q_table

最后的最后,把训练的参数q_table存入文件中,用时间命名。

复制代码
with open(f'qtable_{int(time.time())}.pickle','wb') as f:
    pickle.dump(q_table,f)

这样,我们下一次就可以调用上一次的训练模型继续优化

找到保存的文件名:

在第一部分原本填None的地方填上面的pickle文件,然后从头执行一遍即可。(因为到q_table初始化一段有自动判断,如果有历史训练数据就会自动调用,不再触发初始化)

可以发现,这次直接从-35而不是-150开始训练,直接站在巨人的肩膀上,这次奖励甚至为正数了!

再迭代一轮,奖励就接近10了,非常好。

完整代码

导入依赖库

python 复制代码
#将学习环境所需要的依赖库导入
import numpy as np
import cv2
from PIL import Image
import time
import pickle #对象到文件的处理
import matplotlib.pyplot as plt
from matplotlib import style
style.use('ggplot')

填写参数

python 复制代码
#环境参数设定
SIZE=10 #3个对象在10*10的范围内
EPISODES=30000 #智能体玩的游戏轮数
SHOW_EVERY=3000 #每3000局展示一次游玩过程

#奖励与惩罚
FOOD_REWARD=25#吃食物的奖励
ENEMY_PENALITY=300 #被敌人抓住的惩罚
MOVE_PENALITY=1 #移动惩罚

#环境计算参数
epsilon=0.6 #在强化学习时抽取随机动作的概率 40%使用最大价值期望动作
EPS_DECAY=0.9998 #每玩一局游戏就让随机动作概率乘以这个数,到最后基本就定性了
DISCOUNT=0.95 #折扣回报 未来奖励的折扣
LEARNING_RATE=0.1 #学习率 步长

#q_table_file = "q_table_save.pkl" 
#q_table = None
q_table = "qtable_1775139173.pickle"
d = {1:(255,0,0), #蓝色------玩家
     2:(0,255,0), #绿色------食物
     3:(0,0,255)} #红色------敌人
PLAYER_N=1
FOOD_N=2
ENEMY_N=3

创建对象类

python 复制代码
#为三个对象创建类
class Cube:
    def __init__(self):#初始位置
        self.x=np.random.randint(0,SIZE-1)
        self.y=np.random.randint(0,SIZE-1)

    def __str__(self): #打印当前位置
        return f'{self.x},{self.y}'

    def __sub__(self,other):#这个类的另一个实体
        return (self.x-other.x,self.y-other.y)

    def action(self,choise):
        if choise == 0:
            self.move(x=1,y=1)
        elif choise == 1:
            self.move(x=-1,y=1)
        elif choise == 2:
            self.move(x=1,y=-1)
        elif choise == 3:
            self.move(x=-1,y=-1)

    def move(self,x=False,y=False):
        if not x:#如果x没有给值
            self.x += np.random.randint(-1,2)
        else:
            self.x += x

        if not y:#如果y没有给值
            self.y += np.random.randint(-1,2)
        else:
            self.y += y

        #考虑边界
        if self.x<0:
            self.x=0
        elif self.x>=SIZE:
            self.x=SIZE-1
        if self.y<0:
            self.y=0
        elif self.y>=SIZE:
            self.y=SIZE-1
            

初始化q_table

python 复制代码
#q_table初始化 整个状态空间有19^4个状态 存储玩家和食物、玩家和敌人的距离作为状态,二者距离差在-9-9之间,有19*19个状态,三个对象有361*361个对象
#内部存储当前最佳的动作

# 初始化Q表格
if q_table is None:   # 如果没有实现提供,就随机初始化一个Q表格
    q_table = {}
    for x1 in range(-SIZE+1,SIZE):
        for y1 in range(-SIZE + 1, SIZE):
            for x2 in range(-SIZE + 1, SIZE):
                for y2 in range(-SIZE + 1, SIZE):
                    q_table[((x1,y1),(x2,y2))] = [np.random.randint(-5,0) for i in range(4)]
    #这是值,一个包含 4 个元素的列表。
    #np.random.uniform(-5,0):给这 4 个动作随机赋一个负数的初始价值(比如 -2.3, -1.5, -4.0, -0.5)。
    #注意:这里存的是价值,不是概率。如果想看概率,需要额外的数学运算(如 Softmax),但 Q-Learning 通常直接用 argmax 拿最大值的索引来作为动作。
else:                # 提供了,就使用提供的Q表格
    with open(q_table,'rb') as f:
        q_table= pickle.load(f)
                    

智能体动作选择

python 复制代码
#建立实例-设计超时步数-初始化环境-得到环境状态-选择动作(e-greedy)-执行动作-计算奖励-
#得到new_q(计算当前q值、计算新状态、计算未来最大q)【用q new_obs max_future_q reward】更新-
#更新q_table[obs][action]=new_q(这里是当前obs,new_obs是为了估计max_future_q)的
episode_rewards=[]
for episode in range(EPISODES):
    player=Cube()
    food=Cube()
    enemy=Cube()

    # 每隔一段时间设定show为True,显示图像
    if episode % SHOW_EVERY == 0:
        print('episode ',episode,'  epsilon:',epsilon)
        print('mean_reward:',np.mean(episode_rewards[-SHOW_EVERY:]))
        show = True
    else:
        show = False

    episode_reward=0
    for i in range(200):#超过200步结束
        #拿到环境状态值
        obs=(player-food,player-enemy)#obs的状态 类型要和定义的q_table的状态一致
        #ε-Greedy
        if np.random.random()>epsilon:
            #Q-Learning就是在Q表中找到对应状态的最佳动作
            action=np.argmax(q_table[obs])#argmax就是取最大值的索引(0-3) 
        else:
            action = np.random.randint(0,4)#左闭右开

        #print(player)
        #print(obs)
        #print(action)
        #执行动作
        player.action(action)
        #food.action
        #print('after action:')
        #print(player)

        #计算奖励
        if player.x==food.x and player.y==food.y:
            reward=FOOD_REWARD
        elif player.x==enemy.x and player.y == enemy.y:
            reward = -ENEMY_PENALITY
        else:
            reward= -MOVE_PENALITY

        #print(f'reward:{reward}')

        #更新q_table
        #得到当前的q值
        current_q=q_table[obs][action]#从 Q 表中取出:在当前状态 obs 下,执行动作 action 对应的 Q 值
        #print(f'current_q:{current_q}')

        #更新表格需要 新obs和权衡后的q值
        #得到新的状态
        new_obs=(player-food,player-enemy)
        #print(f'new_obs{new_obs}')

        max_future_q=np.max(q_table[new_obs])
        #print(f'max_future_q:{max_future_q}')
        #逻辑:智能体当前可能因为 ϵ -greedy 探索策略而做了一个"傻事"(比如撞墙),或者做了一个次优动作。
        #目标:但我们更新 Q 值时,不应该参考那个"傻事",而是要参考如果我们在那个新位置(s')重新开始,最聪明的做法能拿多少分。
        #结果:这使得 Q-Learning 的更新目标(Target)总是朝着"最好"的方向修正,最终收敛到最优解。
        if reward==FOOD_REWARD:#如果吃到了食物,就用实际奖励更新
            new_q=FOOD_REWARD
        else:
            new_q=(1-LEARNING_RATE)*current_q + LEARNING_RATE*(reward + DISCOUNT*max_future_q)
        #print(f'new_q:{new_q}')

        q_table[obs][action]=new_q


        # 图像显示
        if show:
            env = np.zeros((SIZE,SIZE,3),dtype= np.uint8)
            env[food.x][food.y] = d[FOOD_N]
            env[player.x][player.y] = d[PLAYER_N]
            env[enemy.x][enemy.y] = d[ENEMY_N]
            img = Image.fromarray(env,'RGB')
            img = img.resize((800,800))
            cv2.imshow('',np.array(img))
            if reward == FOOD_REWARD or reward == -ENEMY_PENALITY:
                if cv2.waitKey(500) & 0xFF == ord('q'):
                    break
            else:
                if cv2.waitKey(1) & 0xFF == ord('q'):
                    break
        
        #加上这一步的奖励
        episode_reward+=reward

        if reward==FOOD_REWARD or reward==-ENEMY_PENALITY:
            break #如果吃到食物或被杀死就结束这一轮


    #轮批次处理
    episode_rewards.append(episode_reward)
    epsilon*=EPS_DECAY

        

刻画统计量曲线

python 复制代码
moving_avg = np.convolve(episode_rewards, np.ones((SHOW_EVERY,))/SHOW_EVERY,mode='valid')
plt.plot([i for i in range(len(moving_avg))], moving_avg)
plt.xlabel('episode #')
plt.ylabel(f'mean{SHOW_EVERY} reward')
plt.show()

保存q_table

python 复制代码
with open(f'qtable_{int(time.time())}.pickle','wb') as f:
    pickle.dump(q_table,f)
相关推荐
山顶夕景2 天前
【MLLM】GraphWalker:Deepresearch用于图像生成
大模型·强化学习·图像生成·rl·agentic
机器觉醒时代3 天前
RL Token:破解 VLA “最后一厘米”精度难题,在线强化学习实现机器人精准操控
人工智能·机器人·强化学习·具身智能·vla模型
码农垦荒笔记4 天前
LLM 后训练革命:GRPO、DAPO 与 RLVR 如何替代 RLHF 重塑大模型对齐训练
人工智能·强化学习·grpo·dapo
威化饼的一隅5 天前
【大模型LLM学习】从强化学习到GRPO【下】
大模型·llm·agent·强化学习·智能体·grpo
威化饼的一隅5 天前
【大模型LLM学习】从强化学习到GRPO【上】
大模型·llm·agent·强化学习·智能体·grpo
靴子学长5 天前
GRPO 深度解析 (TRL 源码视角)
大模型·强化学习·算法设计·大模型推理·源码解读
简简单单做算法5 天前
基于Q-Learning强化学习的小车倒立摆平衡控制系统matlab性能仿真
算法·matlab·强化学习·qlearning·小车倒立摆平衡控制
小刘的AI小站6 天前
L9 Policy Gradient Method (二)
算法·机器学习·强化学习
小刘的AI小站6 天前
L9 Policy Gradient Method (一)
强化学习