用Python实战MDP:从零实现一个强化学习智能体(附完整代码)

如果你对强化学习(Reinforcement Learning)的理论概念感到抽象,觉得那些关于状态、动作、奖励的公式离实际代码很远,那么这篇文章正是为你准备的。我们绕开冗长的纯理论推导,直接进入实战环节。想象一下,你正在构建一个能玩简单游戏的AI,或者一个能自动优化某个流程的智能体。它的核心大脑,就是一个对马尔科夫决策过程(Markov Decision Process, MDP)有深刻理解的算法。今天,我们就用Python,从最基础的MDP组件开始,亲手搭建这个“大脑”,并看着它在模拟环境中学习成长。这篇文章面向的是已经掌握Python基础,渴望通过动手编码来理解强化学习内核的开发者。我们将构建一个完整的、可运行的智能体,过程中你会遇到并解决那些教科书上不会讲的报错和调试难题。

1. 环境搭建与MDP核心组件手写

在开始训练智能体之前,我们必须先定义它所在的世界。一个MDP世界由五个核心要素构成:状态集(S)、动作集(A)、状态转移概率(P)、奖励函数(R)和折扣因子(γ)。我们将用纯粹的Python代码来定义它们,而不是直接调用某个库的现成接口。这样做的目的是让你透彻理解每一个矩阵和函数背后的意义。

首先,我们创建一个简单的网格世界(Grid World)环境。假设有一个4x4的网格,智能体从左上角(0,0)出发,目标是到达右下角(3,3)。碰到网格边界则保持原地不动。这是一个经典的 episodic MDP(分幕式MDP)。

import numpy as np
from typing import Tuple, Dict, List

class GridWorldMDP:
    """
    一个简单的4x4网格世界MDP实现。
    """
    def __init__(self, size=4, gamma=0.99):
        self.size = size
        self.n_states = size * size  # 状态总数
        self.n_actions = 4  # 动作:0:上, 1:右, 2:下, 3:左
        self.gamma = gamma  # 折扣因子

        # 定义状态空间和动作空间
        self.states = [i for i in range(self.n_states)]
        self.actions = [i for i in range(self.n_actions)]

        # 构建状态转移矩阵 P(s' | s, a) 和奖励函数 R(s, a, s')
        # 我们将用字典来存储,键为(s, a),值为列表,列表元素为(概率, 下一状态, 奖励, 是否终止)
        self.P = {}
        self._build_dynamics()

    def _state_to_coord(self, s: int) -> Tuple[int, int]:
        """将状态编号转换为二维坐标。"""
        return (s // self.size, s % self.size)

    def _coord_to_state(self, row: int, col: int) -> int:
        """将二维坐标转换为状态编号。"""
        return row * self.size + col

    def _is_terminal(self, s: int) -> bool:
        """判断状态是否为终止状态(目标或陷阱)。"""
        # 在我们的简单示例中,只有右下角(3,3)是终止状态
        return s == self.n_states - 1

    def _build_dynamics(self):
        """手动构建MDP的动态特性(转移概率和奖励)。"""
        for s in range(self.n_states):
            if self._is_terminal(s):
                # 终止状态,任何动作都转移到自身,奖励为0
                for a in self.actions:
                    self.P[(s, a)] = [(1.0, s, 0.0, True)]
                continue

            row, col = self._state_to_coord(s)
            for a in self.actions:
                next_row, next_col = row, col
                if a == 0:  # 上
                    next_row = max(row - 1, 0)
                elif a == 1:  # 右
                    next_col = min(col + 1, self.size - 1)
                elif a == 2:  # 下
                    next_row = min(row + 1, self.size - 1)
                elif a == 3:  # 左
                    next_col = max(col - 1, 0)

                next_s = self._coord_to_state(next_row, next_col)
                # 简单奖励设定:到达目标状态奖励+1,其他转移奖励为-0.01(鼓励尽快到达目标)
                reward = 1.0 if self._is_terminal(next_s) else -0.01
                done = self._is_terminal(next_s)
                # 确定性环境:概率为1.0
                self.P[(s, a)] = [(1.0, next_s, reward, done)]

    def step(self, s: int, a: int) -> Tuple[int, float, bool]:
        """
        执行一步动作。
        返回: (下一状态, 奖励, 是否终止)
        """
        transitions = self.P.get((s, a), [])
        # 在实际随机环境中,这里需要根据概率采样。我们这里是确定性的。
        prob, next_s, reward, done = transitions[0]
        return next_s, reward, done

    def reset(self) -> int:
        """重置环境到起始状态。"""
        return 0  # 状态0,即(0,0)

注意:上面的_build_dynamics函数是理解MDP“动力学”的关键。它明确定义了环境对智能体动作的响应规则。在更复杂的随机环境中,self.P[(s, a)]列表里会包含多个(概率, 下一状态, 奖励)元组。

现在,我们已经有了一个完全由代码定义的MDP环境。接下来,我们需要一个能在这个环境中做决策的智能体。智能体的核心是策略(Policy)价值函数(Value Function)。我们先来实现一个最简单的智能体:它有一个策略(可能是随机的),并能评估在当前策略下每个状态的价值。

2. 策略评估与价值迭代算法实现

智能体要学习,首先得知道自己在当前策略下表现得“好不好”。这就是策略评估(Policy Evaluation)要解决的问题:计算给定策略π下的状态价值函数 Vπ(s)。我们采用经典的动态规划方法——迭代策略评估。

class MDPAgent:
    def __init__(self, mdp: GridWorldMDP):
        self.mdp = mdp
        self.V = np.zeros(mdp.n_states)  # 状态价值函数
        self.Q = np.zeros((mdp.n_states, mdp.n_actions))  # 动作价值函数
        self.policy = np.ones((mdp.n_states, mdp.n_actions)) / mdp.n_actions  # 初始随机策略

    def policy_evaluation(self, theta=1e-6, max_iter=1000):
        """
        迭代策略评估。
        theta: 价值函数收敛阈值
        max_iter: 最大迭代次数
        """
        for i in range(max_iter):
            delta = 0.0
            V_new = self.V.copy()
            for s in range(self.mdp.n_states):
                if self.mdp._is_terminal(s):
                    continue
                v = 0
                # 对当前策略下每个可能的动作进行求和
                for a in range(self.mdp.n_actions):
                    prob_a = self.policy[s, a]  # 策略π(a|s)
                    # 对环境的可能转移进行求和 (贝尔曼期望方程)
                    for (trans_prob, next_s, reward, _) in self.mdp.P[(s, a)]:
                        v += prob_a * trans_prob * (reward + self.mdp.gamma * self.V[next_s])
                V_new[s] = v
                delta = max(delta, abs(v - self.V[s]))
            self.V = V_new
            if delta < theta:
                print(f"策略评估在 {i+1} 次迭代后收敛。")
                break
        return self.V

仅仅评估策略还不够,我们的目标是找到最优策略。价值迭代(Value Iteration)算法将策略改进和策略评估结合,直接寻找最优价值函数 V*,进而导出最优策略。

    def value_iteration(self, theta=1e-6, max_iter=1000):
        """
        价值迭代算法,直接求解最优价值函数V*和最优策略π*。
        """
        for i in range(max_iter):
            delta = 0.0
            V_new = self.V.copy()
            for s in range(self.mdp.n_states):
                if self.mdp._is_terminal(s):
                    continue
                # 贝尔曼最优方程:V*(s) = max_a Σ P(s'|s,a)[R + γV*(s')]
                action_values = []
                for a in range(self.mdp.n_actions):
                    q = 0
                    for (trans_prob, next_s, reward, _) in self.mdp.P[(s, a)]:
                        q += trans_prob * (reward + self.mdp.gamma * self.V[next_s])
                    action_values.append(q)
                # 取能获得最大价值的行为对应的价值
                V_new[s] = max(action_values)
                delta = max(delta, abs(V_new[s] - self.V[s]))
            self.V = V_new
            if delta < theta:
                print(f"价值迭代在 {i+1} 次迭代后收敛。")
                break
        # 根据最优价值函数提取确定性最优策略
        self._extract_optimal_policy()
        return self.V

    def _extract_optimal_policy(self):
        """从最优价值函数V*中提取确定性最优策略π*。"""
        for s in range(self.mdp.n_states):
            if self.mdp._is_terminal(s):
                continue
            action_values = []
            for a in range(self.mdp.n_actions):
                q = 0
                for (trans_prob, next_s, reward, _) in self.mdp.P[(s, a)]:
                    q += trans_prob * (reward + self.mdp.gamma * self.V[next_s])
                action_values.append(q)
            # 找到价值最大的动作
            best_a = np.argmax(action_values)
            # 将策略设置为确定性策略
            self.policy[s, :] = 0.0
            self.policy[s, best_a] = 1.0

运行上述代码,你将看到智能体通过计算,自己“规划”出了一条从起点到终点的最优路径。价值迭代的本质是离线规划,它要求智能体完全知晓环境的动态模型(即self.P)。这在很多实际问题中是一个强假设。

3. 与OpenAI Gym集成与智能体训练

为了让我们手写的智能体在一个更标准、更丰富的环境中进行测试和学习,我们将其与OpenAI Gym集成。Gym提供了大量标准化的环境,但我们这里选择将其“包装”一下,以适配我们自定义的MDP逻辑,同时展示如何与Gym API交互。

首先,确保你已安装gym库:pip install gym。我们将创建一个Gym风格的包装器。

import gym
from gym import spaces

class GridWorldGymEnv(gym.Env):
    """将我们自定义的GridWorldMDP包装成Gym环境。"""
    metadata = {'render.modes': ['human']}

    def __init__(self, size=4):
        super(GridWorldGymEnv, self).__init__()
        self.mdp = GridWorldMDP(size=size)
        # 定义Gym规范的动作和观察空间
        self.action_space = spaces.Discrete(self.mdp.n_actions)
        self.observation_space = spaces.Discrete(self.mdp.n_states)
        self.state = None
        self.size = size

    def reset(self):
        self.state = self.mdp.reset()
        return self.state

    def step(self, action):
        next_state, reward, done = self.mdp.step(self.state, action)
        self.state = next_state
        # Gym的step返回: observation, reward, done, info
        return next_state, reward, done, {}

    def render(self, mode='human'):
        grid = np.arange(self.mdp.n_states).reshape(self.size, self.size)
        for i in range(self.size):
            row_str = ''
            for j in range(self.size):
                s = self._coord_to_state(i, j)
                if s == self.state:
                    row_str += ' A '  # 智能体位置
                elif self.mdp._is_terminal(s):
                    row_str += ' G '  # 目标位置
                else:
                    row_str += ' . '
            print(row_str)
        print('---')

    def _coord_to_state(self, row, col):
        return row * self.size + col

现在,我们可以用这个环境来训练一个不需要环境模型的智能体,例如Q-learning算法。这是一种经典的时序差分(Temporal Difference, TD)学习算法,属于模型无关的强化学习方法。

class QLearningAgent:
    def __init__(self, env, alpha=0.1, gamma=0.99, epsilon=0.1):
        self.env = env
        self.alpha = alpha  # 学习率
        self.gamma = gamma  # 折扣因子
        self.epsilon = epsilon  # 探索率
        self.n_actions = env.action_space.n
        self.n_states = env.observation_space.n
        self.Q = np.zeros((self.n_states, self.n_actions))

    def choose_action(self, state):
        """ε-greedy策略选择动作。"""
        if np.random.random() < self.epsilon:
            return self.env.action_space.sample()  # 探索
        else:
            return np.argmax(self.Q[state])  # 利用

    def learn(self, state, action, reward, next_state, done):
        """Q-learning更新规则。"""
        best_next_action = np.argmax(self.Q[next_state])
        td_target = reward + self.gamma * self.Q[next_state, best_next_action] * (not done)
        td_error = td_target - self.Q[state, action]
        self.Q[state, action] += self.alpha * td_error

    def train(self, episodes=1000):
        """训练智能体。"""
        rewards_per_episode = []
        for episode in range(episodes):
            state = self.env.reset()
            total_reward = 0
            done = False
            while not done:
                action = self.choose_action(state)
                next_state, reward, done, _ = self.env.step(action)
                self.learn(state, action, reward, next_state, done)
                state = next_state
                total_reward += reward
            rewards_per_episode.append(total_reward)
            if (episode + 1) % 100 == 0:
                avg_reward = np.mean(rewards_per_episode[-100:])
                print(f"Episode {episode+1}, 最近100轮平均奖励: {avg_reward:.2f}")
        return rewards_per_episode

运行训练过程,你会看到智能体从随机探索开始,平均奖励逐渐提高,最终学会快速找到目标。这个过程完美诠释了强化学习的核心:通过试错和与环境的交互来学习最优行为策略

4. 实战调试与性能优化技巧

在亲手实现上述代码的过程中,你几乎一定会遇到各种问题。下面是一些常见的坑和解决方案,这些是纯理论教程里很少涉及的实战经验。

常见报错1:维度不匹配或索引越界

  • 场景:在计算self.Q[state, action]时,state可能是一个由(row, col)组成的元组,而不是一个整数索引。
  • 解决方案:确保你的状态表示是离散且可哈希的。如果使用原始坐标,最好像我们之前做的那样,将其线性映射为一个整数。在Gym的Discrete观察空间中,状态已经是整数。

常见报错2:算法不收敛,奖励曲线震荡或没有提升

  • 可能原因与调试步骤
    1. 学习率(α)过高或过低:学习率太大可能导致Q值震荡,太小则学习缓慢。可以尝试设置一个衰减的学习率,例如 alpha = 0.1 / (1 + episode * 0.01)
    2. 探索率(ε)设置不当:始终高探索率(如ε=0.5)会导致智能体无法充分利用学到的知识;始终低探索率(如ε=0.01)则可能陷入局部最优。实现ε衰减策略是常用技巧。
      self.epsilon = max(0.01, self.epsilon * 0.995)  # 指数衰减
      
    3. 奖励函数设计不合理:这是最核心也最容易出错的地方。如果每一步的奖励都是-1,而目标奖励是0,智能体可能会发现“原地不动”和“走向目标”的长期收益没有区别。我们的设计(到达目标+1,其他步-0.01)给予了明确的优化方向。
    4. 折扣因子(γ)接近1:在分幕式任务中,γ可以设为1。但在持续任务或非常长的分幕任务中,γ需要小于1(如0.99)以确保回报是有限的。

性能优化与可视化 为了更直观地理解智能体的学习过程,我们可以将策略和价值函数可视化。

import matplotlib.pyplot as plt

def plot_learning_curve(rewards_history, window=50):
    """绘制平滑后的奖励曲线。"""
    smoothed_rewards = np.convolve(rewards_history, np.ones(window)/window, mode='valid')
    plt.figure(figsize=(10, 6))
    plt.plot(rewards_history, alpha=0.3, label='原始奖励')
    plt.plot(range(window-1, len(rewards_history)), smoothed_rewards, label=f'{window}轮移动平均', linewidth=2)
    plt.xlabel('训练轮次 (Episode)')
    plt.ylabel('每轮总奖励')
    plt.title('智能体训练学习曲线')
    plt.legend()
    plt.grid(True)
    plt.show()

def visualize_policy(agent, env):
    """在网格中可视化最终学到的策略。"""
    policy_arrows = ['↑', '→', '↓', '←']
    grid_policy = []
    for s in range(env.mdp.n_states):
        if env.mdp._is_terminal(s):
            grid_policy.append('G')
        else:
            # 对于Q-learning,策略是贪婪策略
            best_action = np.argmax(agent.Q[s])
            grid_policy.append(policy_arrows[best_action])
    grid_policy = np.array(grid_policy).reshape(env.size, env.size)
    print("学到的策略(箭头表示最优动作方向):")
    for row in grid_policy:
        print(' '.join(row))

将训练后的智能体进行可视化,你会看到一个清晰的箭头网格,指向从每个状态出发的最佳方向。这比任何数字表格都更能证明你的智能体真正“学会”了。

从玩具环境到更复杂挑战 当你成功运行了4x4网格世界后,可以尝试以下进阶挑战,这些都是强化学习从业者的日常:

  1. 增加随机性:修改GridWorldMDP.step函数,让动作有10%的概率执行失败(随机方向)。观察Q-learning算法能否处理这种随机动态。
  2. 引入“陷阱”:在网格中设置几个状态,进入后获得大额负奖励(如-1)。这模拟了现实任务中的风险。
  3. 更换环境:尝试用Gym中的FrozenLake-v1(自带随机冰面)或CliffWalking-v0环境替换我们的自定义环境。这些环境有更复杂的动态,测试你的智能体代码的通用性。
  4. 实现更高级算法:如Double DQN、Policy Gradient等。这需要你将状态从离散扩展到连续(或高维离散),并引入神经网络作为函数近似器。

手写代码实现MDP和基础RL算法的过程,就像亲手搭建了一台机器的内部齿轮。你不再只是调用agent.learn()的黑盒,而是清楚地知道每一次价值更新、每一次策略改进背后的数学原理和代码逻辑。当你的智能体在屏幕上第一次成功地、稳定地找到目标时,那种对agent-environment interface形式化框架的理解,会比阅读十篇理论文章更加深刻。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐