宏动作

MacroPlanningTetrisEnv 用一个动作把一个方块一直放到底。动作编号是目标列×4 + 目标旋转,一共 40 个。在环境内部先调整好旋转,再左右移动,然后直接落下。一个方块原本要做 20 多次决策,现在减少到一次,消行的奖励也直接附加到选择该方块位置的动作上。

结果

宏 DQN(dqn.zip)训练了 30 万步,也就是 30 万个方块。以下是相同种子 20 局的结果。

  • 随机宏动作:平均 25 个方块,0 行
  • DQN 宏动作:平均 12 个方块,0 行

它比随机结束得更早。20 局里它只选了 5 种动作,其中 13 号(第 3 列,旋转 1)和 23 号(第 5 列,旋转 3)占了 78%。不管是什么方块,它都扔到中间的两个位置。

动作表示挡住了最左边的一列

方块形状画在一个 4×4 的框里,引擎的 x 是这个框的左边缘。竖着的 I 位于框的第三格,所以要把它放到最左边一列,x 必须是 -2。宏动作把目标列限制在 0~9,因此无法选择这样的位置。

七种方块可以放置的位置共有 162 个,用 40 个动作能选到的有 151 个。缺少的 11 个,是把竖 I、O、竖 T、J、L 放在最左边一列(8 个)和第二列(3 个)的位置。

我们给第 5 篇中出现的启发式规划器加上同样的限制试了一下。使用所有位置时,20 局平均消除 370 行;只使用 x 大于等于 0 的位置时,只有 7.9 行。最左边一列一旦变成深井,就没有方块能填进去,那一列会一直空到最后。

用规则给候选打分的 MoE(moe_macro_policy.py)也是在这 40 个动作之上运行的,平均只消除了 12.4 行。MoE 的最终棋盘中,最左边一列也大部分是空的。

VLM 奖励

我们想用 VLM 来弥补消行奖励稀少的问题。LLMRewardWrapper 每 10 步把棋盘画成图像给 Qwen2.5-VL-3B 看,以 JSON 形式接收 -1~1 的分数和下一个动作。训练奖励是引擎奖励 + 0.2×分数。其余 9 步沿用上一次的分数。推荐的动作只记录不使用。读取文字棋盘的 Qwen2.5-3B(GGUF)裁判也以同样的结构包含在内。

开启这个奖励训练的结果没有保留下来。宏 DQN 是在 VLM 奖励代码出现之前保存的,而 37 万步的低层模型是用什么奖励设置训练的,也没有记录。

所以我们单独测试了裁判。先用启发式把棋盘推进到中盘,然后在同一个棋盘上,分别展示把方块放在得分第一的位置和得分最后的位置的结果。得分最后的位置平均会新产生 7.0 个空洞。

  • 15 对的平均分:第一的位置 -0.77,最后的位置 -0.79
  • 给第一的位置更高分的有 4 对,同分的 10 对,更低分的 1 对
  • 得到的分数不是 -0.8 就是 -0.7

按 0.2 倍加进去,只不过是给几乎每一步都同样加上约 -0.15 的惩罚,位置的好坏根本传达不出去。

这一阶段的实际代码

把一个宏动作展开成低层动作,放下一个方块。把目标列限制在 0~9 的那一行,就是挡住最左边一列的地方。

macro_planning_env.pypython · 40 行
def step(self, action: int):
    target_rot = int(action) % 4
    target_col = int(action) // 4
    if target_col < 0 or target_col >= self._w:
        target_col = int(np.clip(target_col, 0, self._w - 1))

    start_pi, _, _, _, _ = self._piece_state()
    total_reward = 0.0
    terminated = truncated = False
    info: dict[str, Any] = {}

    for _ in range(self._max_micro_steps):
        pi, ptype, px, py, prot = self._piece_state()
        if self._micro._lib._dll.tetris_game_over(self._micro._h):
            obs = self._micro._obs()
            terminated = True
            info = {
                "lines_cleared_total": int(
                    self._micro._lib._dll.tetris_lines_cleared_total(self._micro._h)
                ),
                "piece_index": int(self._micro._lib._dll.tetris_piece_index(self._micro._h)),
                "macro_steps": _ + 1,
            }
            return obs, total_reward, terminated, truncated, info

        if pi > start_pi:
            break

        if self._controller is not None:
            # RL low-level controller (Discrete(7))
            micro_a, _ = self._controller.predict(obs, deterministic=True)
            micro = int(micro_a)
        else:
            micro = next_micro_for_target(ptype, prot, px, target_rot, target_col)
        obs, r, terminated, truncated, info = self._micro.step(micro)
        total_reward += float(r)
        if terminated or truncated:
            info = dict(info)
            info["macro_steps"] = _ + 1
            return obs, total_reward, terminated, truncated, info

这是把 VLM 分数加到奖励上的部分。

llm_reward_wrapper.pypython · 15 行
def step(self, action: int):
    self._last_action = int(action)
    obs, reward, terminated, truncated, info = self.env.step(action)

    # LLM judge uses obs after action
    next_action, score = self.teacher.judge_and_suggest(obs, self._last_action, dict(info))
    shaped = float(reward) + float(self.cfg.alpha) * float(score)

    info = dict(info)
    info["llm_score"] = float(score)
    info["llm_next_action"] = int(next_action)
    info["reward_env"] = float(reward)
    info["reward_shaped"] = float(shaped)

    return obs, shaped, terminated, truncated, info

下一篇是“4. 让 VLM 直接选择方块的位置”。