OpenSpiel 状态重采样 API 详解:resample_from_infostate 的原理、实现与实战

原创2026-10-12 10:26:431,422 阅读
文章标签:人工智能强化学习机器学习

OpenSpiel 状态重采样 API 详解:resample_from_infostate 的原理、实现与实战

导读

在扑克、桥牌、Liars Dice 这类不完全信息博弈中,一个玩家的"信息状态"(Information State)只包含其私有信息与公开信息,对手的私有牌/骰子是不可见的。OpenSpiel 提供的 resample_from_infostate 方法能够在保持指定玩家视角完全不变的前提下,为其余玩家重新采样一份私有信息,从而生成一段与当前信息状态一致的新历史(History)。它是实现 ISMCTS(Information Set Monte Carlo Tree Search)等"世界采样"类算法的关键基石。读完本文,你将掌握该 API 的签名与语义、rng 随机源的用法、默认实现的行为边界,以及 OpenSpiel 各游戏(Kuhn Poker、Leduc Poker、Liars Dice 等)的源码级实现模式。


1. 方法签名与核心语义

resample_from_infostate 是 OpenSpiel 核心 API 参考中定义在 State 类上的方法,其 Python 签名为:

resample_from_infostate(player_id: int, rng: callable)

对应的 C++ 虚函数声明位于 open_spiel/spiel.h:

virtual std::unique_ptr<State> ResampleFromInfostate(
    int player_id, std::function<double()> rng) const;

方法的核心语义可以概括为三句话:

  1. 保持 player_id 的私有信息不变——重采样后的新状态中,该玩家看到的世界与原来完全一致;
  2. 保持所有公开信息不变——包括已经发生的公开行动序列、公开牌面、下注历史等;
  3. 重采样其他玩家的私有信息——其他玩家的私有牌/骰子等被重新随机生成,且机会节点(Chance Node)的结果从一致的可能性中均匀采样(sampled uniformly from consistent possibilities)。

换句话说,重采样得到的是一段"在信息状态层面与原始状态无法区分"的新历史,但底层具体的世界(对手私牌的具体取值)可能完全不同。spiel.h 的注释进一步指出:对于存在部分隐藏动作(partially-revealed actions)的游戏,采样时会从与 player_id 观察结果一致的动作列表中均匀采样。

2. rng 参数:如何提供随机源

rng 是一个可调用对象(callable),每次调用返回一个 [0, 1) 区间内的浮点数,用于从机会动作中进行采样。spiel.h 的源码注释明确说明:

For rng, we need something that returns a double in [0, 1). This value will be interpreted as a cumulative distribution function, and will be used to sample from the legal chance actions. A good choice would be absl/std::uniform_real_distribution(0., 1.).

即该返回值会被解释为累积分布函数(CDF)的值,用于按概率抽取合法机会动作。因此:

  • 返回值必须落在 [0, 1),否则采样行为未定义;
  • 推荐使用标准库的均匀分布,如 C++ 中的 std::uniform_real_distribution<double>(0., 1.),或 Python 中的 random.random();
  • 如果你希望重采样过程可复现,可以给 rng 传入绑定到固定种子的随机源。

OpenSpiel 自身也提供了现成的采样器:Python 端可通过 pyspiel.UniformProbabilitySampler(seed, lower, upper) 构造(见 open_spiel/python/algorithms/ismcts.py),C++ 端则有 UniformProbabilitySampler 类(见 open_spiel/tests/basic_tests.cc 中的用法)。

3. 完美信息游戏:默认实现即"克隆"

对于完美信息游戏(Perfect Information Game),每个玩家的信息状态就是完整状态,不存在任何私有信息,因此"重采样"没有意义。此时直接返回当前状态的一个克隆即可。

这一行为由 State 基类的默认实现保证,位于 open_spiel/spiel.cc:

std::unique_ptr<State> State::ResampleFromInfostate(
    int player_id,
    std::function<double()> rng) const {
  if (GetGame()->GetType().information ==
      GameType::Information::kPerfectInformation) {
    return Clone();
  }
  SpielFatalError("ResampleFromInfostate() not implemented.");
}

该实现的逻辑非常清晰:

  • 首先通过 GetGame()->GetType().information 检查游戏类型是否等于 kPerfectInformation;
  • 若是,则调用 Clone() 返回克隆状态;
  • 否则(即不完全信息游戏且该游戏未重写此虚函数),抛出 SpielFatalError("ResampleFromInfostate() not implemented.") 致命错误。

这正是 API 文档中"对于完美信息游戏,返回当前状态的克隆;对于不完全信息游戏,游戏必须实现此方法"这句说明的源码出处。因此,在不完全信息游戏上调用此方法前,务必确认该游戏已实现 ResampleFromInfostate,否则会直接抛错。

4. Python 实战:Kuhn Poker 重采样示例

API 参考文档给出了一个完整的 Python 示例(Kuhn Poker),这是最直接的上手路径:

import pyspiel
import random

game = pyspiel.load_game("kuhn_poker")
state = game.new_initial_state()
state.apply_action(0)   # Deal card 0 to player 0
state.apply_action(1)   # Deal card 1 to player 1

# Resample from player 0's perspective
resampled = state.resample_from_infostate(
    0, lambda: random.random())
# Player 0 still has card 0, but player 1's card may differ

运行逻辑拆解:

  1. pyspiel.load_game("kuhn_poker") 加载标准 Kuhn Poker 游戏;
  2. state.apply_action(0)、state.apply_action(1) 依次处理两个机会动作,分别给玩家 0 和玩家 1 发牌(牌面为牌组中的第 0、1 张);
  3. 以玩家 0 的视角调用 resample_from_infostate(0, lambda: random.random());
  4. 返回值 resampled 是一个新的、独立的状态对象,在该状态中玩家 0 仍然持有原来的牌(牌 0),但玩家 1 的牌被重新采样——可以是除牌 0 外的任意一张剩余牌(Kuhn Poker 共 3 张牌,每人 1 张)。

可以验证不变性与变化性:

# 信息状态字符串保持不变(玩家 0 视角)
print(state.information_state_string(0) == resampled.information_state_string(0))
# 玩家 0 的私有牌保持不变
print(state.information_state_tensor(0) == resampled.information_state_tensor(0))

以上两点也正是 OpenSpiel 测试套件 open_spiel/tests/basic_tests.cc 中对所有实现的核心校验标准(详见第 7 节)。

5. 源码剖析:各游戏如何实现重采样

ResampleFromInfostate 是一个虚函数,各不完全信息游戏在自己的 State 子类中按需重写。其通用实现模式是:从初始状态出发,重新执行机会阶段(保住目标玩家的私有信息、重采样他人私有信息),再重放公开行动序列。下面以三个典型游戏为例。

5.1 Kuhn Poker:最简实现范式

Kuhn Poker 的实现位于 open_spiel/games/kuhn_poker/kuhn_poker.cc:

std::unique_ptr<State> KuhnState::ResampleFromInfostate(
    int player_id, std::function<double()> rng) const {
  std::unique_ptr<State> state = game_->NewInitialState();
  Action player_chance = history_.at(player_id).action;
  for (int p = 0; p < game_->NumPlayers(); ++p) {
    if (p == history_.size()) return state;
    if (p == player_id) {
      state->ApplyAction(player_chance);
    } else {
      Action other_chance = player_chance;
      while (other_chance == player_chance) {
        other_chance = SampleAction(state->ChanceOutcomes(), rng()).first;
      }
      state->ApplyAction(other_chance);
    }
  }
  SPIEL_CHECK_GE(state->CurrentPlayer(), 0);
  if (game_->NumPlayers() == history_.size()) return state;
  for (int i = game_->NumPlayers(); i < history_.size(); ++i) {
    state->ApplyAction(history_.at(i).action);
  }
  return state;
}

关键点:

  • 从 game_->NewInitialState() 重新开始;
  • 对每个玩家 p 依次处理发牌:若 p == player_id 则直接沿用原历史中的牌(history_.at(player_id).action);否则通过 SampleAction(state->ChanceOutcomes(), rng()) 反复采样,确保不抽到 player_id 的那张牌(while 循环);
  • 发牌阶段结束后,若原历史中还有行动,则按原样重放公开行动(下注/过牌序列),因为这些行动对玩家 0 是可见的,属于公开信息;
  • 值得注意的是,Kuhn Poker 的实现没有重采样"对手的行动"——因为这些行动是公开的,必须保持一致。

5.2 Leduc Poker:引入公开牌面

Leduc Poker 的实现位于 open_spiel/games/leduc_poker/leduc_poker.cc,在 Kuhn Poker 的基础上增加了**公开牌(public card)**的处理:

std::unique_ptr<State> LeducState::ResampleFromInfostate(
    int player_id, std::function<double()> rng) const {
  std::unique_ptr<State> clone = game_->NewInitialState();
  // First, deal out cards:
  Action player_chance = history_.at(player_id).action;
  for (int p = 0; p < GetGame()->NumPlayers(); ++p) {
    if (p == player_id) {
      clone->ApplyAction(history_.at(p).action);
    } else {
      Action chosen_action = player_chance;
      while (chosen_action == player_chance || chosen_action == public_card_) {
        chosen_action = SampleAction(clone->ChanceOutcomes(), rng()).first;
      }
      clone->ApplyAction(chosen_action);
    }
  }
  for (int action : round1_sequence_) clone->ApplyAction(action);
  if (public_card_ != kInvalidCard) {
    clone->ApplyAction(public_card_);
    for (int action : round2_sequence_) clone->ApplyAction(action);
  }
  return clone;
}

与 Kuhn Poker 的差异在于:重采样其他玩家私牌时,while 条件除了避开目标玩家的牌 player_chance 外,还避开了公开牌 public_card_(公开牌对所有玩家可见,必须保持不变)。随后依次重放第一轮行动序列、公开牌、第二轮行动序列。这种"重放公开轨迹"的模式在不完全信息牌类游戏中具有普遍性。

5.3 Liars Dice:多机会节点与终止态保护

Liars Dice(吹牛骰子)的实现位于 open_spiel/games/liars_dice/liars_dice.cc:

std::unique_ptr<State> LiarsDiceState::ResampleFromInfostate(
    int player_id, std::function<double()> rng) const {
  if (IsTerminal() || IsChanceNode()) {
    return Clone();
  }
  std::unique_ptr<State> state = game_->NewInitialState();
  int chance_idx = 0;
  for (Player p = 0; p < num_players_; ++p) {
    for (int d = 0; d < num_dice_[p]; ++d) {
      if (p == player_id) {
        state->ApplyAction(history_[chance_idx].action);
      } else {
        Action sampled_action =
            SampleAction(state->ChanceOutcomes(), rng()).first;
        state->ApplyAction(sampled_action);
      }
      chance_idx++;
    }
  }
  SPIEL_CHECK_GE(state->CurrentPlayer(), 0);
  for (size_t i = total_num_dice_; i < history_.size(); ++i) {
    state->ApplyAction(history_[i].action);
  }
  return state;
}

该实现展示了两点额外细节:

  • 边界保护:如果当前状态已经是终止态或机会节点,直接返回 Clone()(此时重采样没有意义);
  • 多机会节点处理:每个玩家持有多个骰子,机会阶段包含多个独立的机会动作(每个骰子一次),因此使用双重循环按 num_dice_[p] 逐个处理;目标玩家的所有骰子原样保留,其他玩家的骰子逐个重采样。

除上述三款游戏外,仓库中实现该方法的游戏还包括:bargaining、hearts、colored_trails、euchre、blackjack、gin_rummy、bridge、oh_hell、universal_poker、connect_four 等(可通过 ResampleFromInfostate 关键字在 open_spiel/games 目录中检索验证)。

6. 典型应用:ISMCTS 中的世界采样

resample_from_infostate 最有价值的应用场景是 ISMCTS(Information Set Monte Carlo Tree Search),该算法通过反复"重采样对手私有信息"来构造多个世界样本,在每个世界样本上执行确定性 MCTS。

C++ 侧调用链位于 open_spiel/algorithms/is_mcts.cc:

  • SampleRootState 在搜索开始时调用 ResampleFromInfostate(state) 生成世界样本;若设置了 max_world_samples_ 上限,则会维护一个根样本池,满后从池中随机复用;
  • ResampleFromInfostate 优先使用用户提供的 resampler_cb_ 回调,否则回退到 state.ResampleFromInfostate(state.CurrentPlayer(), ...),即直接调用本文介绍的 API。

Python 侧对应实现位于 open_spiel/python/algorithms/ismcts.py,其中 resample_from_infostate 方法在未提供自定义回调时,会调用:

state.resample_from_infostate(
    state.current_player(),
    pyspiel.UniformProbabilitySampler(
        self._random_state.randint(np.iinfo(np.int32).max), 0., 1.))

即使用 pyspiel.UniformProbabilitySampler 作为 rng。从这里可以看出:凡是使用 ISMCTS 求解的游戏,其 State 类几乎都必须实现 ResampleFromInfostate——这正是第 5 节中大量牌类游戏实现该方法的直接原因。

7. 测试验证:信息状态一致性如何被保证

OpenSpiel 的通用测试套件 open_spiel/tests/basic_tests.cc 中实现了 ResampleInfostateTest,用于对所有支持该方法的游戏做一致性校验:

// Verifies that ResampleFromInfostate is correctly implemented.
void ResampleInfostateTest(const Game& game, int num_sims) {
  std::mt19937 rng;
  UniformProbabilitySampler sampler;
  for (int i = 0; i < num_sims; ++i) {
    std::unique_ptr<State> state = game.NewInitialState();
    while (!state->IsTerminal()) {
      if (!state->IsChanceNode()) {
        for (int p = 0; p < state->NumPlayers(); ++p) {
          std::unique_ptr<State> other_state =
              state->ResampleFromInfostate(p, sampler);
          SPIEL_CHECK_EQ(state->InformationStateString(p),
                         other_state->InformationStateString(p));
          SPIEL_CHECK_EQ(state->InformationStateTensor(p),
                         other_state->InformationStateTensor(p));
          SPIEL_CHECK_EQ(state->CurrentPlayer(), other_state->CurrentPlayer());
        }
      }
      std::vector<Action> actions = state->LegalActions();
      std::uniform_int_distribution<int> dis(0, actions.size() - 1);
      Action action = actions[dis(rng)];
      state->ApplyActionWithLegalityCheck(action);
    }
  }
}

该测试从初始状态开始随机走棋,在每一个非机会节点上,对每一位玩家 p 调用 ResampleFromInfostate(p, sampler),然后校验三条不变式:

  1. InformationStateString(p) 完全相等——重采样前后的信息状态字符串一致;
  2. InformationStateTensor(p) 完全相等——信息状态张量(神经网络输入)一致;
  3. CurrentPlayer() 相等——轮到行动的玩家不变。

这三个断言正是该 API 语义的精确落地:在"谁在行动、我看到了什么"两个层面,重采样状态与原始状态对目标玩家不可区分。任何新实现若破坏了这些不变式,都会在通用测试中被捕获。

8. 注意事项与边界条件

使用 resample_from_infostate 时需留意以下几点:

  • 必须确认游戏已实现:在完美信息游戏上调用会得到克隆(安全);但在未实现该方法的不完全信息游戏上调用,会触发 SpielFatalError 直接终止程序。调用前可通过 pyspiel.registered_names() 与游戏文档确认,或在代码中先做一次试调用捕获异常;
  • 不修正对手策略:spiel.h 中配套的 GetHistoriesConsistentWithInfostate 注释特别说明,重采样不会按对手策略校正概率,因此除了首个非机会节点外,得到的样本分布并不严格对应真实世界分布——在多次采样求期望时需注意该偏差;
  • 均匀采样假设:重采样时机会结果按均匀分布采样,而非按游戏定义的概率分布。若游戏机会动作本身不均匀,重采样得到的近似统计量会偏;
  • 终端态与机会节点:部分实现(如 Liars Dice)在 IsTerminal() || IsChanceNode() 时直接返回克隆,调用方应避免在这两类节点上依赖重采样的"随机性";
  • 性能开销:每次重采样都从初始状态重新构建并重放公开动作序列,复杂度与历史长度线性相关;在高频调用(如 ISMCTS 每步搜索多次采样)场景下,可通过 max_world_samples 限制根样本数量来摊薄成本。

9. 关联 API 一览

resample_from_infostate 与以下核心 API 密切相关,可一并查阅:

此外,C++ 侧可结合 open_spiel/algorithms/is_mcts.cc 与 open_spiel/python/algorithms/ismcts.py 了解该 API 在真实搜索算法中的完整调用链;测试逻辑可参考 open_spiel/tests/basic_tests.cc。

结语

resample_from_infostate 是 OpenSpiel 中连接"信息状态抽象"与"世界采样算法"的枢纽 API:它以一行调用完成"保留目标玩家视角、重采样对手世界"的复杂操作,其默认实现为完美信息游戏提供了安全的克隆回退,而各不完全信息游戏则遵循"重放机会+重放公开行动"的统一模式给出定制实现。理解它的语义与边界,是你在 OpenSpiel 中开发 ISMCTS、对手建模或任何需要"从信息状态采样世界"的研究工作的第一步。

登录后查看全文
open_spiel