OpenSpiel 状态重采样 API 详解:resample_from_infostate 的原理、实现与实战
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;
方法的核心语义可以概括为三句话:
- 保持
player_id的私有信息不变——重采样后的新状态中,该玩家看到的世界与原来完全一致; - 保持所有公开信息不变——包括已经发生的公开行动序列、公开牌面、下注历史等;
- 重采样其他玩家的私有信息——其他玩家的私有牌/骰子等被重新随机生成,且机会节点(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
运行逻辑拆解:
pyspiel.load_game("kuhn_poker")加载标准 Kuhn Poker 游戏;state.apply_action(0)、state.apply_action(1)依次处理两个机会动作,分别给玩家 0 和玩家 1 发牌(牌面为牌组中的第 0、1 张);- 以玩家 0 的视角调用
resample_from_infostate(0, lambda: random.random()); - 返回值
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),然后校验三条不变式:
InformationStateString(p)完全相等——重采样前后的信息状态字符串一致;InformationStateTensor(p)完全相等——信息状态张量(神经网络输入)一致;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 密切相关,可一并查阅:
- state_information_state_string.md:获取指定玩家视角的信息状态字符串,是验证重采样不变性的第一手段;
- state_information_state_tensor.md:获取信息状态张量,直接用于神经网络的输入一致性校验;
- state_clone.md:克隆状态,是完美信息游戏重采样的默认行为;
- state_chance_outcomes.md:获取机会节点的动作及概率,重采样采样过程的底层依据;
- state_history.md:查看完整历史,理解公开行动序列的重放机制。
此外,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、对手建模或任何需要"从信息状态采样世界"的研究工作的第一步。