4、第一个仿真器:搭建一个简单的 Grid World 环境

好,咱们今天来动手搭第一个仿真环境。

说实话,很多初学者一上来就啃复杂的机器人仿真、自动驾驶仿真,结果被各种依赖库和配置搞得头大。我个人习惯是:从最简模型开始。你想想看,连一个 4x4 的格子世界都跑不通,谈何复杂的连续控制?

4.1 什么是 Grid World?

Grid World,说白了就是一个棋盘。智能体(Agent)在格子上走来走去,每个格子代表一个状态,智能体可以执行上下左右等动作,走到某个格子会得到对应的奖励。

我在项目中遇到过类似的需求:当时要给一个仓储机器人做路径规划验证,但真实场地还没建好。我就用 Grid World 先模拟了一遍,把货架位置设为障碍物,充电桩设为终点。嗯,效果还不错,至少提前发现了两个逻辑漏洞。

核心四要素:

  • 状态空间:所有格子的集合,比如 4x4=16 个状态
  • 动作空间:智能体可以执行的动作,比如上、下、左、右
  • 转移函数:执行某个动作后,从当前状态到下一个状态的映射
  • 奖励函数:进入某个状态或执行某个动作后获得的即时奖励

4.2 定义状态与动作

我们先定一个小目标:一个 4x4 的 Grid World。

状态怎么表示?最简单的方式就是给每个格子编号。从左上角 (0,0) 开始,到右下角 (3,3) 结束。我习惯用一维索引,比如第 0 行第 0 列是状态 0,第 0 行第 1 列是状态 1,以此类推。

动作呢?四个方向:上(0)、下(1)、左(2)、右(3)。

小技巧: 动作用整数编码,后续做 Q-learning 或 DQN 时可以直接当索引用,省去类型转换的麻烦。

4.3 转移函数的设计

转移函数是仿真器的核心。它决定了:你在状态 s 执行动作 a,会到哪个状态 s'?

这里有个坑,我曾经踩过:边界处理。比如智能体在左上角 (0,0) 想往上走,怎么办?

我的做法是:撞墙不动。也就是说,如果执行动作后超出边界,就停留在原地。这样既简单又符合直觉。

另外,我还加了一个障碍物设定。比如把 (1,1) 和 (2,2) 设为障碍物,智能体走进去就弹回原地,并且给一个负奖励。

注意: 转移函数必须是确定性的吗?不一定。你可以加入随机性,比如有 10% 的概率滑到旁边的格子。这在模拟真实机器人打滑时很有用。

4.4 奖励函数的设计

奖励函数决定了智能体的学习方向。设计得好,训练事半功倍;设计得不好,智能体可能学会一些奇怪的行为。

我一般这样设计:

  • 到达终点:+10 分
  • 走进障碍物:-5 分
  • 正常移动:-0.1 分(鼓励智能体尽快到达终点)

为什么要给正常移动一个小的负奖励?你想想看,如果不给惩罚,智能体可能会在终点附近来回转悠,反正也没损失。加一个时间惩罚,它就会想办法走最短路径。

4.5 代码实现

下面是我写的一个简单版本。代码不长,但麻雀虽小五脏俱全。

import numpy as np

class GridWorld:
    def __init__(self, size=4, obstacles=None, goal=None):
        self.size = size
        self.obstacles = obstacles if obstacles else [(1,1), (2,2)]
        self.goal = goal if goal else (3,3)
        self.state = (0, 0)  # 起点
        
        # 动作映射:0上 1下 2左 3右
        self.actions = {
            0: (-1, 0),  # 上
            1: (1, 0),   # 下
            2: (0, -1),  # 左
            3: (0, 1)    # 右
        }
    
    def reset(self):
        self.state = (0, 0)
        return self.state
    
    def step(self, action):
        row, col = self.state
        dr, dc = self.actions[action]
        new_row, new_col = row + dr, col + dc
        
        # 边界检查:撞墙不动
        if new_row < 0 or new_row >= self.size or new_col < 0 or new_col >= self.size:
            new_row, new_col = row, col
        
        # 障碍物检查:撞障碍物弹回
        if (new_row, new_col) in self.obstacles:
            new_row, new_col = row, col
            reward = -5
            done = False
        elif (new_row, new_col) == self.goal:
            reward = 10
            done = True
        else:
            reward = -0.1
            done = False
        
        self.state = (new_row, new_col)
        return self.state, reward, done

# 测试一下
env = GridWorld()
state = env.reset()
print(f"起点: {state}")

state, reward, done = env.step(1)  # 向下走
print(f"向下走一步: {state}, 奖励: {reward}")

state, reward, done = env.step(1)  # 再向下
print(f"再向下: {state}, 奖励: {reward}")

4.6 核心逻辑流程图

下面这张图展示了 Grid World 仿真器的核心运行逻辑。从初始化到每一步的状态更新,再到终止判断,一目了然。

初始化环境 获取当前状态 s 执行动作 a 是否撞墙/障碍? 是 状态不变 否 更新状态 计算奖励 r

4.7 避坑指南

最后分享几个我踩过的坑:

  • 状态表示的一致性:我曾经在状态用 (row, col) 元组,但在 Q-table 里用一维索引,结果 debug 了一下午。建议从一开始就统一表示方式。
  • 奖励尺度:奖励值不要太大也不要太小。我见过有人给终点奖励 1000,结果 Q 值爆炸。一般控制在 [-10, 10] 之间比较稳妥。
  • 随机种子:如果加了随机转移,记得固定随机种子,否则复现结果时会很痛苦。

好了,第一个仿真器就搭完了。虽然简单,但它是后续所有复杂仿真环境的基础。把这个吃透了,后面学连续控制、多智能体都会轻松很多。

扩展思考: 如果想把 Grid World 变成 3D 的,或者加入动态障碍物,你会在哪些地方改代码?