OpenAI Gym逐步学习笔记 -- 自定义场景(2)-- 添加游戏规则并集成深度学习强化算法,实现游戏自我训练

239 阅读25分钟

前言

  • 我们使用 Gym 目标是训练一个我们想玩的游戏,但是 Gym 使用方法中,最重要的就是先给游戏定义一个训练场景,这个训练场景目前来看并非是通用的,不同的游戏由于玩法规则和游戏对象的不同,那么需要定义的游戏场景也是不同的,现在 Gym 官方提供的游戏场景是有限的且单一的,而且都是一些非常老旧单一的像素游戏,所以我们需要自己为游戏开发训练场景,然后再加上 DQN(深度学习) 或者其他学习算法,才能最终训练出来一个模型,才能实现让程序协助我们玩游戏,当然这套思路同样适用于让程序协助我们做其他事情...;

  • 那么如何才能实现一个游戏训练场景呢,我们先不用想太多,我在摸索过程中,找到了一条适合小白逐步摸索学习的路线

  • 本文便是这条路线的第二步:加入 DQN深度学习算法,并实现训练数据持久化,实现真正的游戏训练

  • (感兴趣的话,可以关注一波)

  • 参考链接:

【附】pip 国内镜像源记录:

  • 国内要想顺利安装某些国外的工具包,最好还是用国内镜像源比较方便
其他国内镜像源
中国科学技术大学 : https://pypi.mirrors.ustc.edu.cn/simple
豆瓣:http://pypi.douban.com/simple/
阿里云:http://mirrors.aliyun.com/pypi/simple/
# 使用方式如:
pip3 install numpy -i https://pypi.tuna.tsinghua.edu.cn/simple

环境准备(这里不作详细展开,可自行网上搜索)

  • windows11
  • Anaconda -- 使用虚拟环境来控制安装 python 的不同版本如此比较方便,有的时候因为工具包的限制,同一个python版本不一定能适用所有场景
  • python3.9

1 - 添加小圆球,添加得分,形成游戏规则

  • 我们定义一个最简单的游戏规则,以便让后续训练时,学习算法有所依据
    • 游戏规则为:一个红色小圆球随机从顶部某个位置以一个固定速度向下移动,若底部蓝色矩形成功接住小圆球,则增加得分,若小圆球超出观察区域则游戏结束
import gymnasium as gym
from gymnasium.spaces import Box, Discrete
import numpy as np
import pygame
import time
# pip install pillow
from PIL import Image, ImageGrab

class CustomEnv(gym.Env):
    def __init__(self):
        super(CustomEnv, self).__init__()

        # 定义观测空间
        self.observation_space = Box(low=np.array([0, 0]), high=np.array([100, 20]), dtype=np.float32)

        # 定义动作空间
        self.action_space = Discrete(3) # 0:左移, 1:右移, 2:不动

        # 游戏结束标识
        self.gameover = False

        # 定义游戏场景
        # 观察窗体尺寸
        self.screen_width = 300
        self.screen_height = 300

        # 得分
        self.score = 0
        self.score_x = 10
        self.score_y = 10
        self.score_color = (0, 255, 0) # 绿色

        # 小圆参数
        self.circle_start_p_x = -100
        self.circle_p_x = self.circle_start_p_x # 圆心坐标
        self.circle_p_y = 10 # 圆心坐标
        self.circle_r = 8 # 圆半径
        self.circle_color = (255, 0, 0) # 红色
        self.circle_speed = 15 # 圆的移动速度

        # 矩形参数
        self.rect_width = 50
        self.rect_height = 10
        self.rect_x = self.screen_width // 2 - self.rect_width // 2
        self.rect_y = self.screen_height - self.rect_height * 3
        self.rect_color = (0, 0, 255) # 蓝色

        # 加载背景图片
        # self.background_image = pygame.image.load("1.jpg")
        # self.background_image = pygame.transform.scale(self.background_image, (self.screen_width, self.screen_height))

        # 创建缓冲 Surface
        self.buffer_surface = pygame.Surface((self.screen_width, self.screen_height))

        self.screen = None
        self.clock = pygame.time.Clock()
        self.fps = 30  # 设置帧率为 30 FPS

    def reset(self):
        # 重置游戏场景,这里只需要返回一个初始观察值
        self.screen = None
        observation = np.array([0, 0], dtype=np.float32)
        self.render()
        return observation, {}

    def updateCircleParams(self):
        if self.circle_p_x == self.circle_start_p_x:
          # 小圆的坐标从顶部随机 x 坐标开始
          self.circle_p_x = np.random.randint(0, self.screen_width)
        # y 坐标则以一定的速度向下移动
        self.circle_p_y += self.circle_speed

        # 如果小圆超出屏幕范围,则重新设置其位置
        if self.circle_p_y > self.screen_height:
          self.circle_p_x = self.circle_start_p_x
          self.circle_p_y = 0
          # 游戏结束
          self.score = 0
          self.gameover = True

        # 如果小圆碰到蓝色矩形,则重新设置其位置,但是得分会加10分
        if self.circle_p_x >= self.rect_x and self.circle_p_x <= self.rect_x + self.rect_width and \
                self.circle_p_y >= self.rect_y and self.circle_p_y <= self.rect_y + self.rect_height:
          self.circle_p_x = self.circle_start_p_x
          self.circle_p_y = 0
          self.score += 10

    def step(self, action=0):
        # 根据动作更新游戏状态,这里只是返回原始观察值

        # 根据动作更新蓝色矩形的位置
        if action == 0:  # 左移
            self.rect_x = max(0, self.rect_x - 10)
        elif action == 1:  # 右移
            self.rect_x = min(self.screen_width - self.rect_width, self.rect_x + 10)

        # 更新小圆参数
        self.updateCircleParams()

        # --
        self.screen = None
        observation = np.array([0, 0], dtype=np.float32)
        reward = self.score
        terminated = False
        truncated = False
        done = self.gameover
        if self.gameover is True:
            self.gameover = False
        self.render()
        return observation, done, reward, terminated, truncated, {}

    def render(self):
        if not pygame.get_init():
            pygame.init()
        if self.screen is None:
            self.screen = pygame.display.set_mode((self.screen_width, self.screen_height))

        # 截取屏幕区域
        screenshot = ImageGrab.grab(bbox=(50, 50, self.screen_width+100, self.screen_height+100))
        # 将截图转换为 Numpy 数组
        image_array = np.array(screenshot)
        # 确保数组的形状为 (width, height, 3)
        if len(image_array.shape) == 2:
            # 如果是灰度图,则扩展为三通道
            image_array = np.dstack((image_array, image_array, image_array))
        elif image_array.shape[2] == 4:
            # 如果是带 alpha 通道,则删除 alpha 通道
            image_array = image_array[:, :, :3]
        # 将 Numpy 数组转换为 Pygame Surface
        image_surface = pygame.surfarray.make_surface(image_array.transpose((1, 0, 2)))

        # 将背景图片绘制到缓冲 Surface 上
        # self.buffer_surface.blit(self.background_image, (0, 0))
        self.buffer_surface.blit(image_surface, (0, 0))

        # 渲染得分文本
        font = pygame.font.Font(None, 36) # 创建一个36像素大小的系统字体
        score_text = font.render(f"Score: {self.score}", True, self.score_color) # 渲染文本为Surface
        self.buffer_surface.blit(score_text, (self.score_x, self.score_y)) # 将文本Surface绘制到缓冲Surface上

        # 绘制红色小圆到缓冲 Surface 上
        pygame.draw.circle(self.buffer_surface, self.circle_color, (self.circle_p_x, self.circle_p_y), self.circle_r)

        # 绘制蓝色矩形到缓冲 Surface 上
        pygame.draw.rect(self.buffer_surface, self.rect_color, (self.rect_x, self.rect_y, self.rect_width, self.rect_height))

        # 将缓冲 Surface 一次性绘制到屏幕上
        self.screen.blit(self.buffer_surface, (0, 0))
        pygame.display.update()
        # time.sleep(0.05)
        self.clock.tick(self.fps)

# -----------------------------train-----------------------------------------
env = CustomEnv()
observation, info = env.reset()

for _ in range(1000):
    action = env.action_space.sample() # 这里不需要任何动作,只需要渲染
    observation, reward, terminated, truncated, info = env.step(action)
    env.render()

    if terminated or truncated:
        observation, info = env.reset()


env.close()

  • 效果截图:
    image.png

2 - 加入 DQN 深度学习算法

  • 对初学者的建议:

    • 对于初学者来说,下面给出的代码中的 DQN 和 DQNAgent 可以暂时不用去了解,因为这部分思路是通用的,只是对于不同游戏场景需要实现的细节不同,虽然说下次换成其他游戏后,游戏规则啥的都完全不同了,算法上也需要重写,但是对于我们初学者来说,先要能够明白整体玩法,能够实现出一个基础的游戏训练脚本就足够了,待整体会耍了之后,在慢慢去精细得学习其中的算法部分,因为算法涉及到很多理论知识,是一个长途学习旅程,若初学到时候就系统得去学的话需要看人了,大多数人会因为乏味且难以得到成就感而失去学习动力,所以我们通过一个简单的案例,逐步实现一个训练游戏为目标,尽量每一步都能得到些许成就感,逐步增加难度,逐步顺带着学习一点理论知识,切每学习一些理论都能实际运用到实际游戏训练中,这又能得到些许成就感,形成继续学习的动力。
  • 下面这段是对DQN和DQNAgent的简短说明,了解即可:

    • DQN和DQNAgent这两个类是实现了深度Q网络(DQN)这种强化学习算法的核心部分,它们本身是通用的,可以应用于任何强化学习环境。不过,根据不同的环境,我们可能需要对这些类进行一些小的调整。
    • 具体来说:
      • (2.1. DQN类 这个类定义了Q网络的网络结构。在本例中,我们使用了一个简单的3层全连接网络。但是,对于不同的环境,状态的表示形式可能不同,那么输入层的大小就需要做相应调整。同样,如果动作的种类发生变化,输出层的大小也需要调整。但是网络的基本结构(一些全连接层)并不需要改变。
      • 2.2. DQNAgent类 这个类实现了DQN算法的核心逻辑,包括经验回放(experience replay)、目标网络更新等。这部分代码对于大多数DQN环境都是通用的,不需要做太多改动。 不过,根据不同环境的动作空间和观测空间的定义,你可能需要调整以下部分:
        • self.action_sizeself.state_size需要根据新环境的设置进行初始化。
        • self.act()方法中选择动作的逻辑可能需要调整,因为不同环境可能有不同的动作空间类型(如离散还是连续)。
        • self.memorize()方法中存储经验样本的方式可能需要调整,因为状态的表示形式可能发生变化。
    • 总的来说,DQN和DQNAgent这两个类提供了DQN算法的核心框架,是可以在不同环境下复用的。但是,根据新环境的具体情况,你可能需要对它们进行一些小的修改和调整,以适配新的状态表示、动作空间等。这种修改通常是比较简单的。
  • DQN算法重点提醒:

DQNAgent中的replay函数实现的是经验回放机制,这一机制在不同的强化学习任务中是通用的。但是,具体的实现细节可能因游戏环境的不同而有所调整。

从理论上讲,replay函数的核心逻辑是:

1. 从经验回放池中采样批次数据
2. 计算当前Q值q_values
3. 计算目标Q值q_targets  
4. 计算损失函数
5. 反向传播,更新网络参数

这一核心逻辑对于不同的强化学习任务是通用的。

但是,对于不同的游戏环境,可能需要对一些具体实现细节进行调整:

1. **状态数据的预处理**
不同游戏的状态数据形式可能不尽相同,比如有的是图像,有的是连续或离散的数值等。因此需要针对具体情况对状态数据进行适当的预处理。

2. **动作空间的表示**
有些游戏是离散动作空间,有些是连续动作空间,这将影响Q网络的输出形式。

3. **奖励的处理**  
不同游戏环境下奖励的设置方式也可能有所不同,需要根据具体情况进行适当的缩放、修剪等处理。

4. **优化器、损失函数的选择**
不同任务复杂程度不同,可能需要使用不同的优化器和损失函数来获得更好的训练效果。

5. **其他超参数的调整**
比如折扣因子gamma、探索率epsilon的初始值和衰减策略等,也可能需要针对具体任务进行调整。

所以,虽然replay函数的核心算法框架是通用的,但在应用到具体的游戏环境时,还需要根据该环境的特点对算法的实现细节进行相应的调整和定制,以获得最佳的训练效果。一个普遍的做法是,先基于通用的DQNAgent实现一个初始版本的算法,再在此基础上针对具体游戏环境进行必要的修改和完善。
  • 脚本如下:
# 设置使用 GPU 进行训练,稍作修改游戏规则 -- 游戏结束分数清零
import gymnasium as gym
from gymnasium.spaces import Box, Discrete
import numpy as np
import pygame
import time
import random
# pip install pillow
from PIL import Image, ImageGrab
# -- 深度选项算法相关包
import os
import torch
import torch.nn as nn
import torch.optim as optim
from collections import deque
import torch.nn as nn # 用于统一设置训练设备,即使用 GPU 进行训练 <========

cur_dir = os.path.dirname(__file__).replace('\\', '/')
print(cur_dir)

# 设置优先使用 GPU 作为训练设备 <========
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using device: {device}")
print(f"PyTorch Version: {torch.__version__}")

class CustomEnv(gym.Env):
    def __init__(self):
        super(CustomEnv, self).__init__()

        # 定义观测空间
        self.observation_space = Box(low=np.array([0, 0]), high=np.array([100, 20]), dtype=np.float32)

        # 定义动作空间
        self.action_space = Discrete(3) # 0:左移, 1:右移, 2:不动

        # 游戏结束标识
        self.gameover = False

        # 定义游戏场景
        # 观察窗体尺寸
        self.screen_width = 300
        self.screen_height = 300

        # 得分
        self.score = 0
        self.score_x = 10
        self.score_y = 10
        self.score_color = (0, 255, 0) # 绿色

        # 小圆参数
        self.circle_start_p_x = -100
        self.circle_p_x = self.circle_start_p_x # 圆心坐标
        self.circle_p_y = 10 # 圆心坐标
        self.circle_r = 8 # 圆半径
        self.circle_color = (255, 0, 0) # 红色
        self.circle_speed = 15 # 圆的移动速度

        # 矩形参数
        self.rect_width = 50
        self.rect_height = 10
        self.rect_x = self.screen_width // 2 - self.rect_width // 2
        self.rect_y = self.screen_height - self.rect_height * 3
        self.rect_color = (0, 0, 255) # 蓝色

        # 加载背景图片
        # self.background_image = pygame.image.load("1.jpg")
        # self.background_image = pygame.transform.scale(self.background_image, (self.screen_width, self.screen_height))

        # 创建缓冲 Surface
        self.buffer_surface = pygame.Surface((self.screen_width, self.screen_height))

        self.screen = None
        self.clock = pygame.time.Clock()
        self.fps = 30  # 设置帧率为 30 FPS

    def reset(self):
        # 重置游戏场景,这里只需要返回一个初始观察值
        self.screen = None
        observation = np.array([0, 0], dtype=np.float32)
        self.render()
        return observation, {}

    def updateCircleParams(self):
        if self.circle_p_x == self.circle_start_p_x:
          # 小圆的坐标从顶部随机 x 坐标开始
          self.circle_p_x = np.random.randint(0, self.screen_width)
        # y 坐标则以一定的速度向下移动
        self.circle_p_y += self.circle_speed

        # 如果小圆超出屏幕范围,则重新设置其位置
        if self.circle_p_y > self.screen_height:
          self.circle_p_x = self.circle_start_p_x
          self.circle_p_y = 0
          # 游戏结束
          self.score = 0
          self.gameover = True

        # 如果小圆碰到蓝色矩形,则重新设置其位置,但是得分会加10分
        if self.circle_p_x >= self.rect_x and self.circle_p_x <= self.rect_x + self.rect_width and \
                self.circle_p_y >= self.rect_y and self.circle_p_y <= self.rect_y + self.rect_height:
          self.circle_p_x = self.circle_start_p_x
          self.circle_p_y = 0
          self.score += 10

    def step(self, action=0):
        # 根据动作更新游戏状态,这里只是返回原始观察值

        # 根据动作更新蓝色矩形的位置
        if action == 0:  # 左移
            self.rect_x = max(0, self.rect_x - 10)
        elif action == 1:  # 右移
            self.rect_x = min(self.screen_width - self.rect_width, self.rect_x + 10)

        # 更新小圆参数
        self.updateCircleParams()

        # --
        self.screen = None
        observation = np.array([0, 0], dtype=np.float32)
        reward = self.score
        terminated = False
        truncated = False
        done = self.gameover
        if self.gameover is True:
            self.gameover = False
        self.render()
        return observation, done, reward, terminated, truncated, {}

    def render(self):
        if not pygame.get_init():
            pygame.init()
        if self.screen is None:
            self.screen = pygame.display.set_mode((self.screen_width, self.screen_height))

        # 截取屏幕区域
        screenshot = ImageGrab.grab(bbox=(50, 50, self.screen_width+100, self.screen_height+100))
        # 将截图转换为 Numpy 数组
        image_array = np.array(screenshot)
        # 确保数组的形状为 (width, height, 3)
        if len(image_array.shape) == 2:
            # 如果是灰度图,则扩展为三通道
            image_array = np.dstack((image_array, image_array, image_array))
        elif image_array.shape[2] == 4:
            # 如果是带 alpha 通道,则删除 alpha 通道
            image_array = image_array[:, :, :3]
        # 将 Numpy 数组转换为 Pygame Surface
        image_surface = pygame.surfarray.make_surface(image_array.transpose((1, 0, 2)))

        # 将背景图片绘制到缓冲 Surface 上
        # self.buffer_surface.blit(self.background_image, (0, 0))
        self.buffer_surface.blit(image_surface, (0, 0))

        # 渲染得分文本
        font = pygame.font.Font(None, 36) # 创建一个36像素大小的系统字体
        score_text = font.render(f"Score: {self.score}", True, self.score_color) # 渲染文本为Surface
        self.buffer_surface.blit(score_text, (self.score_x, self.score_y)) # 将文本Surface绘制到缓冲Surface上

        # 绘制红色小圆到缓冲 Surface 上
        pygame.draw.circle(self.buffer_surface, self.circle_color, (self.circle_p_x, self.circle_p_y), self.circle_r)

        # 绘制蓝色矩形到缓冲 Surface 上
        pygame.draw.rect(self.buffer_surface, self.rect_color, (self.rect_x, self.rect_y, self.rect_width, self.rect_height))

        # 将缓冲 Surface 一次性绘制到屏幕上
        self.screen.blit(self.buffer_surface, (0, 0))
        pygame.display.update()
        # time.sleep(0.05)
        self.clock.tick(self.fps)

# ----------------------------- DQN 深度学习算法 ------------------------------

# import torch
# import torch.nn as nn
# import torch.optim as optim
# import random
# import numpy as np
# from collections import deque

# 1. 定义DQN网络结构
class DQN(nn.Module):
    def __init__(self, observation_space, action_space):
        super(DQN, self).__init__()
        self.fc1 = nn.Linear(observation_space, 128)
        self.fc2 = nn.Linear(128, 64)
        self.fc3 = nn.Linear(64, action_space)

    def forward(self, x):
        x = torch.relu(self.fc1(x))
        x = torch.relu(self.fc2(x))
        return self.fc3(x)

# 2. 定义DQN智能体
class DQNAgent:
    def __init__(self, observation_space, action_space, learning_rate, epsilon, epsilon_decay, gamma, batch_size):
        self.pthfile_path = cur_dir+'/dqn_agent.pth'
        self.observation_space = observation_space
        self.action_space = action_space
        self.learning_rate = learning_rate
        self.epsilon = epsilon
        self.epsilon_decay = epsilon_decay
        self.gamma = gamma
        self.batch_size = batch_size
        
        # 创建DQN网络
        self.policy_net = DQN(observation_space, action_space)
        self.target_net = DQN(observation_space, action_space)
        
        # 同步target网络的权重
        self.target_net.load_state_dict(self.policy_net.state_dict())
        self.target_net.eval()
        
        # 优化器
        self.optimizer = optim.Adam(self.policy_net.parameters(), lr=learning_rate)
        
        # 经验回放缓冲区
        self.memory = deque(maxlen=2000)
        
        # 损失函数
        self.loss_fn = nn.MSELoss()

    def act(self, state):
        if np.random.rand() <= self.epsilon:
            return random.randint(0, self.action_space - 1)  # 探索:随机选择动作
        else:
            state = torch.tensor(state, dtype=torch.float32)
            # return self.policy_net(state).argmax().item().detach().item()  # 利用:选择Q值最高的动作
            # 利用:选择Q值最高的动作
            argmax_item_result = self.policy_net(state).argmax().item()
            if isinstance(argmax_item_result, int):
                return argmax_item_result
            else:
                return argmax_item_result.detach().item()

    def remember(self, state, action, reward, next_state, done):
        self.memory.append((state, action, reward, next_state, done))

    def learn(self):
        if len(self.memory) < self.batch_size:
            return
    
        # 从经验回放缓冲区中随机抽取一批经验
        experiences = random.sample(self.memory, k=self.batch_size)
        
        states, actions, rewards, next_states, dones = zip(*experiences)
        
        # 将states列表中的每个张量展平并转换为一个二维张量
        # states = torch.stack([torch.tensor(state, dtype=torch.float32).flatten() for state in states], dim=0)
        states = torch.stack([torch.tensor(state, dtype=torch.float32).flatten() for state in states], dim=0)
        
        actions = torch.tensor(actions, dtype=torch.long)
        rewards = torch.tensor(rewards, dtype=torch.float32)
        # 同上,将next_states列表中的每个张量展平并转换为一个二维张量
        next_states = torch.stack([torch.tensor(next_state, dtype=torch.float32).flatten() for next_state in next_states], dim=0)
        
        dones = torch.tensor(dones, dtype=torch.float32)
        
        # 预测当前状态的动作价值
        q_values = self.policy_net(states).gather(1, actions.unsqueeze(-1)).squeeze(-1)
        
        # 使用target网络计算目标Q值
        next_max_q_values = self.target_net(next_states).max(1)[0].detach()
        
        # 如果是游戏结束,则下一个状态的Q值为0
        dones_long = dones.long() # 确保dones是一个长整型张量
        next_max_q_values[dones_long] = 0.0
        
        # 计算目标Q值
        expected_q_values = rewards + self.gamma * next_max_q_values
        
        # 计算损失
        loss = self.loss_fn(q_values, expected_q_values)
        
        # 反向传播和更新
        self.optimizer.zero_grad()
        loss.backward()
        self.optimizer.step()
        
        # 按epsilon_decay减少epsilon
        self.epsilon *= self.epsilon_decay

    def save(self):
        filename = self.pthfile_path
        checkpoint = {
            'policy_net_state_dict': self.policy_net.state_dict(),
            'target_net_state_dict': self.target_net.state_dict(),
            'optimizer_state_dict': self.optimizer.state_dict(),
            'memory': list(self.memory),
            'epsilon': self.epsilon
        }
        torch.save(checkpoint, filename)

    def load(self):
        filename = self.pthfile_path
        # 判断文件不存在
        if os.path.exists(filename):
            checkpoint = torch.load(filename)
            self.policy_net.load_state_dict(checkpoint['policy_net_state_dict'])
            self.target_net.load_state_dict(checkpoint['target_net_state_dict'])
            # self.optimizer.load_dict(checkpoint['optimizer_state_dict'])
            self.optimizer.load_state_dict(checkpoint['optimizer_state_dict'])
            self.memory = deque(checkpoint['memory'])
            self.epsilon = checkpoint['epsilon']
            print('[dqn_agent.pth] file is loaded success!')
        else:
            print('no [dqn_agent.pth] file to load.')

# # 假设观测空间是2维,动作空间是3个动作
# observation_space = 2
# action_space = 3
# learning_rate = 0.001
# epsilon = 1.0
# epsilon_decay = 0.995
# gamma = 0.99
# batch_size = 32

# # 初始化DQN智能体
# agent = DQNAgent(observation_space, action_space, learning_rate, epsilon, epsilon_decay, gamma, batch_size)

# # 使用save和load函数
# # agent.save('my_checkpoint.pth')  # 保存模型和经验
# # agent.load('my_checkpoint.pth')  # 加载模型和经验

# -----------------------------train-----------------------------------------

# import torch
# import numpy as np
# import random

# 假设CustomEnv类和DQNAgent类已经定义好了

# 初始化游戏环境
env = CustomEnv()

# 获取环境的观测空间和动作空间维度
observation_space = env.observation_space.shape[0]
action_space = env.action_space.n

# DQN智能体参数
learning_rate = 0.001
epsilon = 1.0
epsilon_decay = 0.995
gamma = 0.99
batch_size = 32
episodes = 1000

# 初始化DQN智能体
agent = DQNAgent(observation_space, action_space, learning_rate, epsilon, epsilon_decay, gamma, batch_size)
agent.load()

# 训练过程
for episode in range(episodes):
    state = env.reset()[0]  # 假设reset()返回的是元组,我们取第一个元素作为状态
    state = np.array(state).flatten()  # 将状态转换为一维数组
    state = torch.tensor(state, dtype=torch.float32)  # 将numpy数组转换为torch tensor

    episode_reward = 0
    done = False
    
    while not done:
        action = agent.act(state)  # 使用DQNAgent选择动作
        # print('---------------action--------------')
        # print('action:',action)
        # print('----------------------------------')

        # next_state, reward, done, _ = env.step(action)  # 执行动作并获取下一个状态、奖励和是否完成的信息
        next_state, done, reward, terminated, truncated, info = env.step(action) # 执行动作并获取下一个状态、奖励和是否完成的信息
        next_state = np.array(next_state).flatten()  # 将下一个状态转换为一维数组
        next_state = torch.tensor(next_state, dtype=torch.float32)  # 转换为torch tensor

        # 存储经验到回放缓冲区
        agent.remember(state, action, reward, next_state, done)
        
        # 更新当前状态
        state = next_state
        
        # 更新总奖励
        episode_reward += reward
        
        # 学习更新
        if done:
            agent.learn()
        else:
            env.render()  # 渲染游戏画面

    print(f"Episode: {episode}, Total reward: {episode_reward}, Epsilon: {agent.epsilon}")

    # 保存模型权重和经验回放池
    if episode >= 100 and episode % 100 == 0:  # 每100个episode保存一次
        agent.save()
        print(f'-----Checkpoint saved for episode {episode}------')

# 如果你想监控训练过程,可以使用gym的监控工具
# monitor = wrappers.Monitor(env, '/tmp/cartpole-experiment-1', force=True)
# 然后在训练循环中使用 monitor.step() 代替 env.step()

  • 效果截图:
    • (加入深度学习算法对画面没有任何影响)
    • (运行截图只是为了说明能够正常运行)
      image.png

【附】优化过度频繁截屏导致报错的问题

  • 优化说明:由于过度频繁截屏可能会因为系统保护或权限等问题导致不必要的报错,所以我干脆在训练代码中去掉了截屏作为背景图的功能
  • 改为直接读取图片地址并绘制到屏幕上作为背景,这样做的好处是不会再有频繁截屏带来报错问题,而且可以把截屏处理方案通过另一个脚本独立进行实现,减轻训练脚本不必要的负担
  • 后续我们需要通过结合 YOLOV8 对实时游戏画面进行目标识别,也许不一定需要去真实读取背景图片,因为YOLOV8目标识别中带有这份功能的
  • 原本截屏的思路可能存在一定问题,所以这里的截屏功能直接去除就可以了,暂时只需要使用背景图片代替即可
# 设置使用 GPU 进行训练,稍作修改游戏规则 -- 游戏结束分数清零
import gymnasium as gym
from gymnasium.spaces import Box, Discrete
import numpy as np
import pygame
import time
import random
# pip install pillow
from PIL import Image, ImageGrab
# -- 深度选项算法相关包
import os
import torch
import torch.nn as nn
import torch.optim as optim
from collections import deque
import torch.nn as nn # 用于统一设置训练设备,即使用 GPU 进行训练 <========

cur_dir = os.path.dirname(__file__).replace('\\', '/')
print(cur_dir)

# 设置优先使用 GPU 作为训练设备 <========
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using device: {device}")
print(f"PyTorch Version: {torch.__version__}")

class CustomEnv(gym.Env):
    def __init__(self,game_view_img_path):
        super(CustomEnv, self).__init__()

        # 定义观测空间
        self.observation_space = Box(low=np.array([0, 0]), high=np.array([100, 20]), dtype=np.float32)

        # 定义动作空间
        self.action_space = Discrete(3) # 0:左移, 1:右移, 2:不动

        # 游戏结束标识
        self.gameover = False

        # 定义游戏场景
        # 观察窗体尺寸
        self.screen_width = 300
        self.screen_height = 300

        # 得分
        self.score = 0
        self.score_x = 10
        self.score_y = 10
        self.score_color = (0, 255, 0) # 绿色

        # 小圆参数
        self.circle_start_p_x = -100
        self.circle_p_x = self.circle_start_p_x # 圆心坐标
        self.circle_p_y = 10 # 圆心坐标
        self.circle_r = 8 # 圆半径
        self.circle_color = (255, 0, 0) # 红色
        self.circle_speed = 15 # 圆的移动速度

        # 矩形参数
        self.rect_width = 50
        self.rect_height = 10
        self.rect_x = self.screen_width // 2 - self.rect_width // 2
        self.rect_y = self.screen_height - self.rect_height * 3
        self.rect_color = (0, 0, 255) # 蓝色

        # 加载背景图片
        self.game_view_img_path = game_view_img_path
        try:
            # 若背景图片存在,则绘制
            if os.path.exists(self.game_view_img_path):
                self.background_image = pygame.image.load(self.game_view_img_path)
                self.background_image = pygame.transform.scale(self.background_image, (self.screen_width, self.screen_height))
        except Exception as e:
            # 当异常发生时执行的代码
            print(f"load background image failed warning: {e}")

        # 创建缓冲 Surface
        self.buffer_surface = pygame.Surface((self.screen_width, self.screen_height))

        self.screen = None
        self.clock = pygame.time.Clock()
        self.fps = 30  # 设置帧率为 30 FPS

    def reset(self):
        # 重置游戏场景,这里只需要返回一个初始观察值
        self.screen = None
        observation = np.array([0, 0], dtype=np.float32)
        self.render()
        return observation, {}

    def updateCircleParams(self):
        if self.circle_p_x == self.circle_start_p_x:
          # 小圆的坐标从顶部随机 x 坐标开始
          self.circle_p_x = np.random.randint(0, self.screen_width)
        # y 坐标则以一定的速度向下移动
        self.circle_p_y += self.circle_speed

        # 如果小圆超出屏幕范围,则重新设置其位置
        if self.circle_p_y > self.screen_height:
          self.circle_p_x = self.circle_start_p_x
          self.circle_p_y = 0
          # 游戏结束
          self.score = 0
          self.gameover = True

        # 如果小圆碰到蓝色矩形,则重新设置其位置,但是得分会加10分
        if self.circle_p_x >= self.rect_x and self.circle_p_x <= self.rect_x + self.rect_width and \
                self.circle_p_y >= self.rect_y and self.circle_p_y <= self.rect_y + self.rect_height:
          self.circle_p_x = self.circle_start_p_x
          self.circle_p_y = 0
          self.score += 10

    def step(self, action=0):
        # 根据动作更新游戏状态,这里只是返回原始观察值

        # 根据动作更新蓝色矩形的位置
        if action == 0:  # 左移
            self.rect_x = max(0, self.rect_x - 10)
        elif action == 1:  # 右移
            self.rect_x = min(self.screen_width - self.rect_width, self.rect_x + 10)

        # 更新小圆参数
        self.updateCircleParams()

        # --
        self.screen = None
        observation = np.array([0, 0], dtype=np.float32)
        reward = self.score
        terminated = False
        truncated = False
        done = self.gameover
        if self.gameover is True:
            self.gameover = False
        self.render()
        return observation, done, reward, terminated, truncated, {}

    def render(self):
        if not pygame.get_init():
            pygame.init()
        if self.screen is None:
            self.screen = pygame.display.set_mode((self.screen_width, self.screen_height))

        # -------------------------------------截屏方案废弃----------------------------------------------------------
        # 由于频繁截屏会导致可能截屏失败的问题,毕竟系统可能会对此进行保护,所以这份方案废弃了,改成读取背景图片,而那张背景图片可以通过其他程序实现实时截屏并保存截图的功能
        # 如此一来截屏的复杂功能就没必要加入到这里了,毕竟这份脚本主要功能是实现模型训练,尽量减轻不必要的负担

        # # 截取屏幕区域
        # screenshot = ImageGrab.grab(bbox=(50, 50, self.screen_width+100, self.screen_height+100))
        # # 将截图转换为 Numpy 数组
        # image_array = np.array(screenshot)
        # # 确保数组的形状为 (width, height, 3)
        # if len(image_array.shape) == 2:
        #     # 如果是灰度图,则扩展为三通道
        #     image_array = np.dstack((image_array, image_array, image_array))
        # elif image_array.shape[2] == 4:
        #     # 如果是带 alpha 通道,则删除 alpha 通道
        #     image_array = image_array[:, :, :3]
        # # 将 Numpy 数组转换为 Pygame Surface
        # image_surface = pygame.surfarray.make_surface(image_array.transpose((1, 0, 2)))
        # # 把截屏图片绘制到缓冲 Surface 上
        # self.buffer_surface.blit(image_surface, (0, 0))
        # ----------------------------------------------------------------------------------------------

        # 异常捕获
        try:
            # 若背景图片存在,则绘制
            if os.path.exists(self.game_view_img_path):
                self.background_image = pygame.image.load(self.game_view_img_path)
                self.background_image = pygame.transform.scale(self.background_image, (self.screen_width, self.screen_height))
                # 将背景图片绘制到缓冲 Surface 上
                self.buffer_surface.blit(self.background_image, (0, 0))
        except Exception as e:
            # 当异常发生时执行的代码
            print(f"load background image failed warning: {e}")
        

        # 渲染得分文本
        font = pygame.font.Font(None, 36) # 创建一个36像素大小的系统字体
        score_text = font.render(f"Score: {self.score}", True, self.score_color) # 渲染文本为Surface
        self.buffer_surface.blit(score_text, (self.score_x, self.score_y)) # 将文本Surface绘制到缓冲Surface上

        # 绘制红色小圆到缓冲 Surface 上
        pygame.draw.circle(self.buffer_surface, self.circle_color, (self.circle_p_x, self.circle_p_y), self.circle_r)

        # 绘制蓝色矩形到缓冲 Surface 上
        pygame.draw.rect(self.buffer_surface, self.rect_color, (self.rect_x, self.rect_y, self.rect_width, self.rect_height))

        # 将缓冲 Surface 一次性绘制到屏幕上
        self.screen.blit(self.buffer_surface, (0, 0))
        pygame.display.update()
        # time.sleep(0.05)
        self.clock.tick(self.fps)

# ----------------------------- DQN 深度学习算法 ------------------------------

# import torch
# import torch.nn as nn
# import torch.optim as optim
# import random
# import numpy as np
# from collections import deque

# 1. 定义DQN网络结构
class DQN(nn.Module):
    def __init__(self, observation_space, action_space):
        super(DQN, self).__init__()
        self.fc1 = nn.Linear(observation_space, 128)
        self.fc2 = nn.Linear(128, 64)
        self.fc3 = nn.Linear(64, action_space)

    def forward(self, x):
        x = torch.relu(self.fc1(x))
        x = torch.relu(self.fc2(x))
        return self.fc3(x)

# 2. 定义DQN智能体
class DQNAgent:
    def __init__(self, observation_space, action_space, learning_rate, epsilon, epsilon_decay, gamma, batch_size):
        self.pthfile_path = cur_dir+'/dqn_agent.pth'
        self.observation_space = observation_space
        self.action_space = action_space
        self.learning_rate = learning_rate
        self.epsilon = epsilon
        self.epsilon_decay = epsilon_decay
        self.gamma = gamma
        self.batch_size = batch_size
        
        # 创建DQN网络
        self.policy_net = DQN(observation_space, action_space)
        self.target_net = DQN(observation_space, action_space)
        
        # 同步target网络的权重
        self.target_net.load_state_dict(self.policy_net.state_dict())
        self.target_net.eval()
        
        # 优化器
        self.optimizer = optim.Adam(self.policy_net.parameters(), lr=learning_rate)
        
        # 经验回放缓冲区
        self.memory = deque(maxlen=2000)
        
        # 损失函数
        self.loss_fn = nn.MSELoss()

    def act(self, state):
        if np.random.rand() <= self.epsilon:
            return random.randint(0, self.action_space - 1)  # 探索:随机选择动作
        else:
            state = torch.tensor(state, dtype=torch.float32)
            # return self.policy_net(state).argmax().item().detach().item()  # 利用:选择Q值最高的动作
            # 利用:选择Q值最高的动作
            argmax_item_result = self.policy_net(state).argmax().item()
            if isinstance(argmax_item_result, int):
                return argmax_item_result
            else:
                return argmax_item_result.detach().item()

    def remember(self, state, action, reward, next_state, done):
        self.memory.append((state, action, reward, next_state, done))

    def learn(self):
        if len(self.memory) < self.batch_size:
            return
    
        # 从经验回放缓冲区中随机抽取一批经验
        experiences = random.sample(self.memory, k=self.batch_size)
        
        states, actions, rewards, next_states, dones = zip(*experiences)
        
        # 将states列表中的每个张量展平并转换为一个二维张量
        # states = torch.stack([torch.tensor(state, dtype=torch.float32).flatten() for state in states], dim=0)
        states = torch.stack([torch.tensor(state, dtype=torch.float32).flatten() for state in states], dim=0)
        
        actions = torch.tensor(actions, dtype=torch.long)
        rewards = torch.tensor(rewards, dtype=torch.float32)
        # 同上,将next_states列表中的每个张量展平并转换为一个二维张量
        next_states = torch.stack([torch.tensor(next_state, dtype=torch.float32).flatten() for next_state in next_states], dim=0)
        
        dones = torch.tensor(dones, dtype=torch.float32)
        
        # 预测当前状态的动作价值
        q_values = self.policy_net(states).gather(1, actions.unsqueeze(-1)).squeeze(-1)
        
        # 使用target网络计算目标Q值
        next_max_q_values = self.target_net(next_states).max(1)[0].detach()
        
        # 如果是游戏结束,则下一个状态的Q值为0
        dones_long = dones.long() # 确保dones是一个长整型张量
        next_max_q_values[dones_long] = 0.0
        
        # 计算目标Q值
        expected_q_values = rewards + self.gamma * next_max_q_values
        
        # 计算损失
        loss = self.loss_fn(q_values, expected_q_values)
        
        # 反向传播和更新
        self.optimizer.zero_grad()
        loss.backward()
        self.optimizer.step()
        
        # 按epsilon_decay减少epsilon
        self.epsilon *= self.epsilon_decay

    def save(self):
        filename = self.pthfile_path
        checkpoint = {
            'policy_net_state_dict': self.policy_net.state_dict(),
            'target_net_state_dict': self.target_net.state_dict(),
            'optimizer_state_dict': self.optimizer.state_dict(),
            'memory': list(self.memory),
            'epsilon': self.epsilon
        }
        torch.save(checkpoint, filename)

    def load(self):
        filename = self.pthfile_path
        # 判断文件不存在
        if os.path.exists(filename):
            checkpoint = torch.load(filename)
            self.policy_net.load_state_dict(checkpoint['policy_net_state_dict'])
            self.target_net.load_state_dict(checkpoint['target_net_state_dict'])
            # self.optimizer.load_dict(checkpoint['optimizer_state_dict'])
            self.optimizer.load_state_dict(checkpoint['optimizer_state_dict'])
            self.memory = deque(checkpoint['memory'])
            self.epsilon = checkpoint['epsilon']
            print('[dqn_agent.pth] file is loaded success!')
        else:
            print('no [dqn_agent.pth] file to load.')

# # 假设观测空间是2维,动作空间是3个动作
# observation_space = 2
# action_space = 3
# learning_rate = 0.001
# epsilon = 1.0
# epsilon_decay = 0.995
# gamma = 0.99
# batch_size = 32

# # 初始化DQN智能体
# agent = DQNAgent(observation_space, action_space, learning_rate, epsilon, epsilon_decay, gamma, batch_size)

# # 使用save和load函数
# # agent.save('my_checkpoint.pth')  # 保存模型和经验
# # agent.load('my_checkpoint.pth')  # 加载模型和经验

# -----------------------------train-----------------------------------------

# import torch
# import numpy as np
# import random

# 假设CustomEnv类和DQNAgent类已经定义好了

# 初始化游戏环境
game_view_img_path = cur_dir+'/game_view.png'
env = CustomEnv(game_view_img_path)

# 获取环境的观测空间和动作空间维度
observation_space = env.observation_space.shape[0]
action_space = env.action_space.n

# DQN智能体参数
learning_rate = 0.001
epsilon = 1.0
epsilon_decay = 0.995
gamma = 0.99
batch_size = 32
episodes = 1000

# 初始化DQN智能体
agent = DQNAgent(observation_space, action_space, learning_rate, epsilon, epsilon_decay, gamma, batch_size)
agent.load()

# 训练过程
for episode in range(episodes):
    state = env.reset()[0]  # 假设reset()返回的是元组,我们取第一个元素作为状态
    state = np.array(state).flatten()  # 将状态转换为一维数组
    state = torch.tensor(state, dtype=torch.float32)  # 将numpy数组转换为torch tensor

    episode_reward = 0
    done = False
    
    while not done:
        action = agent.act(state)  # 使用DQNAgent选择动作
        # print('---------------action--------------')
        # print('action:',action)
        # print('----------------------------------')

        # next_state, reward, done, _ = env.step(action)  # 执行动作并获取下一个状态、奖励和是否完成的信息
        next_state, done, reward, terminated, truncated, info = env.step(action) # 执行动作并获取下一个状态、奖励和是否完成的信息
        next_state = np.array(next_state).flatten()  # 将下一个状态转换为一维数组
        next_state = torch.tensor(next_state, dtype=torch.float32)  # 转换为torch tensor

        # 存储经验到回放缓冲区
        agent.remember(state, action, reward, next_state, done)
        
        # 更新当前状态
        state = next_state
        
        # 更新总奖励
        episode_reward += reward
        
        # 学习更新
        if done:
            agent.learn()
        else:
            env.render()  # 渲染游戏画面

    print(f"Episode: {episode}, Total reward: {episode_reward}, Epsilon: {agent.epsilon}")

    # 保存模型权重和经验回放池
    if episode >= 100 and episode % 100 == 0:  # 每100个episode保存一次
        agent.save()
        print(f'-----Checkpoint saved for episode {episode}------')

# 如果你想监控训练过程,可以使用gym的监控工具
# monitor = wrappers.Monitor(env, '/tmp/cartpole-experiment-1', force=True)
# 然后在训练循环中使用 monitor.step() 代替 env.step()

总结

  • 我经过实际训练之后,发现本文中的 DQNAgent 中的学习算法是存在问题的,因为经过训练之后的效果是,AI只知道把矩形停留在一处,这样就能有机会得到得分,也就是说目前的学习算法只是让AI学会如何得到得分,而不是如何得到更高的得分,这并没有实现预期目标
  • 不过呢,没关系,这也恰恰是我需要的效果,这样一来更有利于我去逐步优化学习其中的算法,那我的下一篇文章就是分析这里的算法,然后逐步优化成预期的效果
  • (感兴趣就关注一波咯)
【附】-- 优化提示
在训练脚本中,如果AI不能学会去接住小球,可能有几个原因:

1.  **奖励函数设计**:您的奖励函数可能需要重新设计,以鼓励AI接住小球。如果仅仅是接住小球就得分,而没有惩罚小球掉落的机制,AI可能就不会学会去主动接球。
2.  **观测空间**:您的观测空间目前是`Box(low=np.array([0, 0]), high=np.array([100, 20]), dtype=np.float32)`,这个空间可能不足以提供足够的信息让AI学会复杂的决策。您可能需要考虑使用游戏的视觉输入作为观测空间。
3.  **网络结构**:您的DQN可能过于简单,无法学习到复杂的策略。您可以尝试增加网络的复杂性,比如添加更多的层或者使用卷积神经网络(CNN)来处理视觉输入。
4.  **训练时间**:DQN的训练可能需要较长时间才能收敛,特别是当环境较为复杂时。您可能需要更多的训练周期。
5.  **探索策略**:ε-贪心探索策略的参数可能需要调整。如果ε衰减太快,AI可能在学会接球之前就停止了探索。
6.  **学习率和折扣因子**:学习率和折扣因子γ的选择对DQN的学习效果有很大影响。您可能需要尝试不同的值来找到最佳配置。
7.  **目标网络更新**:在DQN中,目标网络通常以一定的时间间隔更新,而不是每次训练步骤都更新。这有助于稳定学习过程。
8.  **经验回放**:确保您正确地实现了经验回放机制,这对于DQN的学习至关重要。
9.  **环境的复位**:在`reset`方法中,您需要确保游戏状态被正确地重置,以便开始新的游戏。

为了改进您的训练脚本,您可以尝试以下步骤:

-   **调整奖励函数**:为小球掉落增加一个负面奖励。
-   **使用视觉输入**:考虑使用游戏屏幕的截图作为输入,而不是简化的数值表示。
-   **增加网络复杂性**:尝试使用更复杂的网络结构。
-   **增加训练时间**:允许更多的训练周期。
-   **调整探索策略**:调整ε的衰减速度。
-   **调整超参数**:尝试不同的学习率和折扣因子。
-   **稳定目标网络更新**:定期更新目标网络,而不是每个步骤都更新。
-   **确保经验回放正确**:检查经验回放的实现是否正确。
-   **重置游戏状态**:在`reset`方法中确保游戏状态被正确重置。

最后,DQN和强化学习通常需要大量的实验和调整才能达到理想的效果