掼蛋AI的动作空间爆炸问题:两阶段决策架构实战

1 阅读4分钟

背景

掼蛋用两副标准扑克牌(108张),四人两两组队。一手牌最多27张,合法出牌组合在某些局面下超过200种。如果用传统的"softmax over所有合法动作"方案,网络根本收敛不了。

本文分享我在掼蛋AI中解决动作空间爆炸问题的实战方案:两阶段决策架构

问题分析

掼蛋的合法出牌类型包括:

  • 单张、对子、三张
  • 顺子(5张+)、连对(3对+)、三连(2组+)
  • 三带二、飞机
  • 炸弹(4张、5张、6张...最高到8张炸)
  • 同花顺
  • 火箭(大小王)

每种类型下还有大量的具体牌组合。例如,仅"顺子"一种类型,5-6-7-8-9、6-7-8-9-10、...再加上癞子替代,可能的选择就有几十种。

直接枚举所有合法动作做softmax:

# 朴素方案(不可行)
legal_actions = enumerate_all_legal_actions(hand)  # 可能200+
logits = policy_network(state)  # output dim = max_actions
probs = softmax(logits[legal_actions])
action = sample(probs)

问题:

  1. output维度不固定,batch训练困难
  2. 200+的softmax梯度信号太稀疏,收敛慢
  3. 不同牌型之间的"结构"信息被完全忽略

两阶段决策架构

第一阶段:选牌型

CARD_TYPES = [
    'PASS',        # 不出
    'SINGLE',      # 单张
    'PAIR',        # 对子
    'TRIPLE',      # 三张
    'STRAIGHT',    # 顺子
    'CONSECUTIVE_PAIRS',  # 连对
    'PLANE',       # 飞机
    'TRIPLE_PAIR', # 三带二
    'BOMB_4',      # 4张炸弹
    'BOMB_5PLUS',  # 5张+炸弹
    'STRAIGHT_FLUSH', # 同花顺
    'ROCKET',      # 火箭
]

# 第一阶段网络
type_logits = type_network(state)  # dim = len(CARD_TYPES)
# mask掉当前手牌不可能出的类型
type_mask = get_feasible_types(hand, last_play)
type_probs = masked_softmax(type_logits, type_mask)
chosen_type = sample(type_probs)

这一步把200+的决策压缩到了12种类型选择。

第二阶段:选具体牌

# 在选定的牌型内枚举所有可能的具体牌组合
candidates = enumerate_type_actions(hand, chosen_type, last_play)
# 通常每种类型内只有5-20个候选

# 第二阶段网络
card_features = encode_candidates(candidates)  # 每个候选编码
card_logits = card_network(state, card_features)
card_probs = softmax(card_logits[:len(candidates)])
chosen_cards = candidates[sample(card_probs)]

关键设计:类型网络和牌网络共享底层

State Encoding (LSTM)
        |
   ┌────┴────┐
   |         |
Type Head  Card Head
   |         |
 12-dim    动态dim

两个头共享同一个LSTM状态编码器。这样"选类型"时的全局判断和"选具体牌"时的细节判断可以共享信息。

训练技巧

1. 类型级别的reward shaping

不能只在最终胜负时给reward。我加了中间奖励:

def step_reward(action_type, game_state):
    reward = 0
    # 出炸弹但不是关键时刻,扣分
    if action_type in ['BOMB_4', 'BOMB_5PLUS'] and not is_critical(game_state):
        reward -= 0.1
    # 帮队友接风,加分
    if is_helping_partner(action_type, game_state):
        reward += 0.05
    return reward

2. 对手动作预测辅助任务

在LSTM编码器上加了一个辅助头,预测对手下一步的出牌类型。这迫使编码器学习"读牌"能力,显著提升了主任务的表现。

# 辅助任务
opponent_type_pred = aux_head(lstm_output)
aux_loss = cross_entropy(opponent_type_pred, actual_opponent_type)
total_loss = policy_loss + value_loss + 0.3 * aux_loss

3. 癞子处理

掼蛋中当前等级的牌是癞子(万能牌)。处理癞子的方式:

def expand_with_wild(hand, wild_rank):
    """将癞子展开为所有可能的替代"""
    wild_count = count_cards(hand, wild_rank)
    if wild_count == 0:
        return [hand]
    # 枚举癞子的所有替代组合
    # 优化:只考虑当前手牌缺少的、能构成牌型的替代
    useful_replacements = get_useful_replacements(hand, wild_rank)
    return [replace_wild(hand, wild_rank, repl) for repl in useful_replacements]

关键优化:不全枚举所有替代可能(组合爆炸),只考虑"有用"的替代——即能让手牌构成某种牌型的替代。

性能对比

方案动作空间收敛速度最终胜率
全动作softmax200+不收敛-
两阶段决策12 + ~151200万局85% vs规则
两阶段+辅助任务12 + ~15800万局89% vs规则
两阶段+辅助+reward shaping12 + ~15600万局92% vs规则

总结

两阶段决策不是什么新idea(Hierarchical RL早就有了),但在掼蛋这个具体场景下效果特别好,因为牌类游戏的动作天然有"类型-实例"的层次结构。

如果你在做其他牌类/棋类AI,遇到动作空间太大的问题,可以考虑类似的分层方案。关键是找到合理的"类型"划分——要让类型之间的策略差异足够大。

有做类似工作的朋友欢迎评论区交流。

参考:

  • Zha et al., "DouZero: Mastering DouDiZhu with Self-Play Deep Reinforcement Learning", ICML 2021
  • Vezhnevets et al., "FeUdal Networks for Hierarchical Reinforcement Learning", ICML 2017