背景
掼蛋用两副标准扑克牌(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)
问题:
- output维度不固定,batch训练困难
- 200+的softmax梯度信号太稀疏,收敛慢
- 不同牌型之间的"结构"信息被完全忽略
两阶段决策架构
第一阶段:选牌型
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]
关键优化:不全枚举所有替代可能(组合爆炸),只考虑"有用"的替代——即能让手牌构成某种牌型的替代。
性能对比
| 方案 | 动作空间 | 收敛速度 | 最终胜率 |
|---|---|---|---|
| 全动作softmax | 200+ | 不收敛 | - |
| 两阶段决策 | 12 + ~15 | 1200万局 | 85% vs规则 |
| 两阶段+辅助任务 | 12 + ~15 | 800万局 | 89% vs规则 |
| 两阶段+辅助+reward shaping | 12 + ~15 | 600万局 | 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