ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

深度强化学习训练德州扑克AI:从Leduc到NFSP算法优化实践

深度强化学习训练德州扑克AI:从Leduc到NFSP算法优化实践 简介这是一份基于Python深度强化学习的德州扑克AI算法优化项目适合人工智能或算法方向的高年级本科生、研究生作为毕设、课程设计或工程实践参考。项目主体为位于“实验环境/agents/DeepCFRagent3.py”的改进型智能体与CFR、CFR、MCCFR、DeepCFR等方法进行了对比实验在Limit/Nolimit Leduc Holdem Poker上用exploitability衡量算法与纳什均衡的距离并针对大规模Limit Holdem Poker环境设计了与随机智能体对战的reward评估具备完整的实验设计和对比分析。压缩包共166个文件包含58个Python源码、48个pth模型权重、18个txt说明、18个csv数据记录等整体约14MB可复现实验流程并查看训练与评估结果。已有139人学习该资源适合需要快速上手深度强化学习博弈实验的读者。1. 基于Python深度强化学习的德州扑克AI算法优化先跑通 Leduc再谈完整版很多人一上来就瞄准六人局无限注德州扑克结果用 Python 写出来的深度强化学习模型怎么训都不收敛甚至几十万局下来还是在乱跟注。原因不是算法不够强而是把“能跑”和“能收敛”混为一谈。德州扑克是不完全信息博弈AI 看不到对手底牌只能从下注行为反推隐藏信息这跟下围棋、玩超级马里奥完全是两个世界。这篇文章解决三个问题这套 AI 该用哪些深度强化学习算法、训练管道怎么搭、以及为什么你的模型总在“一管就死、一放就乱”。我会从两人有限注的 Leduc Holdem 讲起让新手能跟着复现让熟手能跳到最后看超参和避坑。2. 深度强化学习解决德州扑克的关键从不完全信息博弈到可训练的智能体2.1 德州扑克的博弈复杂度在哪儿隐藏牌、下注轮和透支筹码德州扑克的核心难点并不是“牌型组合太多”而是每一手牌都存在信息不对称自己知道两张底牌对手只知道公共牌和自己下注的节奏。深度强化学习处理这种问题不能照搬 Atari 那套只靠像素和奖励的做法因为它没有显式的对手建模很容易让 AI 只学会针对当前对手的固定打法。更麻烦的是下注轮结构。预翻牌、翻牌、转牌、河牌四轮下注每一轮都有加注和再加注筹码深度直接影响策略。同一个牌手在浅筹码时可能玩得紧深筹码时又愿意看翻牌。如果 AI 的输入特征里没有筹码深度和下注轮信息训练出来的策略就会显得“人格分裂”。所以我在项目里会刻意把游戏抽象成“回合制部分可观察马尔可夫决策过程”POMDP。状态是当前玩家的手牌、公共牌、注池筹码、剩余筹码和对手本局历史动作动作是弃牌、跟注、加注奖励是最终获得的筹码增量。这样一来深度强化学习才能真正接手德州扑克。2.2 算法选型DQN、PPO 与 NFSP 的边界很多人问“深度强化学习做德州扑克是不是用 DQN 就行”我的答案是单人局可以用 DQN两人对战很快会翻车因为对手的策略也在变环境对智能体来说是“非平稳”的。DQN 假设环境固定一旦你的 AI 变强了对手池却没变它会过拟合到陈旧打法上。PPO 这类策略梯度算法更稳定但同样要面对非平稳问题。常见做法是引入自博弈让 AI 跟自己的历史版本打而不是跟固定规则牌手打。这又带来一个典型问题只跟当前版本打会导致策略震荡今天赢昨天明天输今天。所以我在简化版项目里推荐 NFSPNeural Fictitious Self-Play它是深度强化学习在德州扑克上最有代表性的方案之一。NFSP 的核心思想是把神经网络分成两支一支是“最佳响应网络”用 DQN 去学如何打败当前对手另一支是“平均策略网络”把历史上多个最佳响应策略做平均作为下一轮自博弈的对手。平均的含义很关键它吸收了历史各种风格避免策略只盯住某个版本打。DeepStack 和很多知名扑克 AI 都用类似思路只是底层换成了反事实遗憾最小化CFR来提供更精确的指导。在这条路径上很多人会问既然 DeepStack 用 CFR 取代深度强化学习那为什么不直接做 CFR原因很现实CFR 需要对博弈树做多次迭代完整德州扑克的状态空间大到必须用蒙特卡洛抽样工程复杂度比深度强化学习高一个量级。如果你只是为了研究深度强化学习在博弈中的表现用 NFSP 这种方案更容易在 Python 里实现和可视化如果目标是追平职业牌手那应该去读 DeepStack 和 Pluribus 的实现把 CFR 和深度网络结合起来。2.3 Python 最小环境搭建一个可复现的 Leduc Holdem 训练骨架不建议一上来就写完整德州扑克规则引擎那会和算法优化抢时间。我一般用 Leduc Holdem 作为基准环境牌组只有 J、Q、K 各两张每人两张底牌翻牌只发一张公共牌两轮下注加注次数受限。这个环境规模小但保留了诈唬、跟注、加注这些博弈要素适合验证算法。# minimal_poker_env.py import random class LeducPokerEnv: 简化版 Leduc Holdem重点暴露 reset/step/obs/action 接口 真实项目的规则引擎建议直接用 RLCard 的内置实现 def __init__(self, n_raise_limit2): self.deck [J, J, Q, Q, K, K] self.n_raise_limit n_raise_limit def reset(self): random.shuffle(self.deck) self.hand {agent: [self.deck[0], self.deck[1]], opponent: [self.deck[2], self.deck[3]]} self.public_card self.deck[4] self.pot 2 # 双方各投入 1 个底注 self.round 0 # 0 代表预翻牌1 代表翻牌后 self.raise_count 0 # 当前轮加注次数 self.legal_actions [fold, call, raise] if self.raise_count self.n_raise_limit else [fold, call] return self._get_obs() def _get_obs(self): return { hand: self.hand[agent], public_card: self.public_card, pot: self.pot, round: self.round, raise_count: self.raise_count, } def step(self, action): # 这里省略了完整的下注结算只示意状态推进 if action fold: done True reward -self.pot / 2 # 弃牌输掉已投入的部分 elif action call: done self.round 1 reward 0.0 else: # raise self.pot 2 self.raise_count 1 done False reward 0.0 return self._get_obs(), reward, done, {}这段代码不是完整引擎它的作用是把环境接口固定下来。真实落地时我通常使用 RLCard 里的 Leduc Holdem 来避免自己写的规则有漏洞。RLCard 的 step 返回状态、奖励、结束标志和合法动作跟上面这个简化版的结构几乎一样。这样做的意义在于后面所有算法代码都只面向这个接口换环境时不用改训练逻辑。从这段演示能看到三个设计要点。第一合法动作不是固定的加注次数到顶后要把 raise 从动作集里剔除否则智能体输出非法动作只能随机化处理。第二奖励在弃牌时要考虑“沉没筹码”如果 AI 已经下注 3 个筹码弃牌应该返回 -3 而不是 0否则训练会偏向乱弃牌。第三环境需要把牌面转成数字索引否则无法直接送入神经网络。这些细节不处理好算法再先进也会在训练里翻车。这套接口定了之后还有个容易忽略的参数动作屏蔽。在扑克环境里如果当前回合只能跟注不能加注你把非法动作硬塞给网络网络会在梯度里学到一种不存在的动作。我的习惯是让网络输出所有动作的分数然后在采样时 mask 掉非法动作只对合法动作做 softmax。这个小改动能让训练加速因为智能体不用花时间探索非法动作。3. 德州扑克AI训练管道落地特征编码、双网络更新和评估曲线3.1 特征工程实例把公共牌、手牌和下注历史转成神经网络输入在完整德州扑克里直接输入“J”和“Q”这种字母没有意义。我用的是三层特征拼接第一层是手牌和公共牌的牌面数字编码第二层是牌力评估是否对子、是否同花顺子、最大牌点数等第三层是下注历史、注池和筹码深度。Leduc 因为只有 J、Q、K牌力评估最简单但足够验证特征设计思路。# feature_encoder.py from itertools import combinations RANK_MAP {J: 11, Q: 12, K: 13} def eval_hand(cards): 评估 Leduc 中的牌力0单牌1对子2三条仅示意 ranks [RANK_MAP[c] for c in cards] if len(ranks) 2: return 1.0 if ranks[0] ranks[1] else 0.0 if len(ranks) 3: if len(set(ranks)) 1: return 2.0 if len(set(ranks)) 2: return 1.0 return 0.0 def encode_features(hand, public_card, round_num, pot, bet_history): # 手牌自身特征 ranks [RANK_MAP[c] for c in hand] pair_feat 1.0 if ranks[0] ranks[1] else 0.0 # 和公共牌组合的牌力 if public_card: combined hand [public_card] strength eval_hand(combined) else: strength 0.0 # 下注历史编码最近的 6 个动作fold/call/raise 分别映射为 0/1/2 hist [0.0] * 6 for i, a in enumerate(bet_history[-6:]): if a call: hist[i] 1.0 elif a raise: hist[i] 2.0 feat [pair_feat, strength, round_num / 2.0, pot / 24.0] return feat hist这里关键点是“牌力评估”不能过于简化。在完整德州扑克里单靠公开牌和手牌算出的牌力并不可靠因为对手底牌范围不同。常见做法是把当前手牌放到一组“疑似对手范围”里去算胜率但这需要做范围枚举。工程上一开始可以直接用“当前已知牌组成什么牌型”作为特征等模型不收敛时再换成范围胜率特征。别一上来就让特征太重。bet_history 编码我特意只保留最近 6 个动作。动作序列太长会让神经网络输入维度过大而且早期动作对当前决策的影响往往被后期动作覆盖。如果你发现模型记不住对手前一轮的下注行为可以把历史长度从 6 改成 10但不要超过 20否则参数增长带来的收益会迅速衰减。3.2 训练循环实例NFSP 双网络更新与自博弈采样前面说过 NFSP 依靠两个网络。最佳响应网络用 DQN 学习平均策略网络用预演出的最优动作做监督学习。这个双网络结构在 Python 里的骨架如下# nfsp_agent.py from collections import deque import random, copy class NFSPAgent: def __init__(self, state_dim, n_actions, lr3e-4, replay_size200_000): self.q_net build_dqn(state_dim, n_actions) self.mean_net build_dqn(state_dim, n_actions) self.target_q_net build_dqn(state_dim, n_actions) self.replay_q deque(maxlenreplay_size) self.replay_sl deque(maxlenreplay_size) self.lr lr self._sync_target() def act(self, obs, modeaverage): if mode best: return self.q_net.argmax(obs) return self.mean_net.sample(obs) # 从概率中采样保证探索 def store_transition(self, obs, action, reward, next_obs, done): self.replay_q.append((obs, action, reward, next_obs, done)) # 平均策略网络的监督样本把 best 动作作为标签 self.replay_sl.append((obs, action)) def update(self): if len(self.replay_q) 512 or len(self.replay_sl) 512: return # 1. 从 replay_q 采样小批量用 target_q_net 计算 TD 目标 # 2. 从 replay_sl 采样小批量训练 mean_net 输出动作概率 # 3. 每隔 C 步把 q_net 权重拷贝到 target_q_net# train_loop.py def train(env, agent, episodes200_000): for ep in range(episodes): obs env.reset() done False # 交替使用 best 和 average让两个网络都有充分的对手多样性 mode best if ep % 2 0 else average while not done: action agent.act(obs, mode) next_obs, reward, done, _ env.step(action) agent.store_transition(obs, action, reward, next_obs, done) obs next_obs if len(agent.replay_q) 512: agent.update() if ep % 1000 0: evaluate(agent)这段代码里的 mode 交替非常关键。如果只让独立于 best 网络之外的平均策略网络去动作最佳响应网络很难看到高质量对手训练进度会慢。交替采样相当于让两个网络互为“陪练”每 1000 局做一次全量评估避免训练到后期才发现策略已经跑偏。NFSP 的超参我默认给到经验回放池大小 200000批量大小 512学习率 3e-4目标网络同步步数 C1000。这些数值不是拍脑袋它们是许多扑克 AI 论文里的常用量级。但注意当你把单人有限注换成多人无限注时回放池需要加大到 500000 以上批量大小也可以酌情调整因为动作空间变大单次更新需要更多样本。3.3 评估曲线以“每千手平均筹码”代替损失值深度强化学习训练日志里最误导人的就是 loss 下降。在扑克里loss 下降可能只代表 Q 值预测趋于稳定不代表策略会赢。我自己的评估函数只关心一个指标每千手平均筹码收益。# evaluate.py def evaluate(agent, n_hands10000): total_chips 0.0 env LeducPokerEnv() for _ in range(n_hands): obs env.reset() done False while not done: action agent.act(obs, modebest) next_obs, reward, done, _ env.step(action) total_chips reward obs next_obs return total_chips / n_hands * 1000评估时一定要固定对手。我通常用一个随机策略或者一个简单的规则策略作为评估对手而不是用训练中的自博弈对手。自博弈对手本身就是另一个 AI胜率波动大很难看出当前模型是否真正进步。固定对手的 10000 手评估配合分段 1000 手计算的方差才能让我放心决定要不要继续训练。如果每千手平均筹码长期在 0 附近徘徊说明算法没有学到优势。这时不要急着加网络层数先回头查特征里有没有把“注池”和“本轮加注次数”漏掉。漏掉注池会导致 AI 判断不了跟注成本漏掉加注次数会导致 AI 不理解规则限制这两个错误我都踩过。4. 算法优化的三个杠杆奖励塑形、多目标优化和超参搜索4.1 奖励塑形把稀疏的终局输赢拆成可学习的反馈德州扑克的天然奖励是一局结束后的筹码变化中间过程全是零奖励。这种稀疏奖励让深度强化学习很难收敛特别是两人对局一局往往很短AI 很难判断是哪一步导致最后输赢。我常用的做法是对奖励做“两步塑形”第一步在弃牌、跟注、加注的关键节点给出小幅度过程奖励比如做出一个“成功诈唬”的行为时奖励 0.1第二步将最终筹码收益归一化到 [-1, 1]让网络不会被大额输赢带偏梯度。注意塑形不能改变最优策略。常见错误是给“跟注”负奖励希望 AI 变得更紧结果它会过度弃牌反而把胜率打崩。做法要兼顾比如只在对手长期弃牌率超过阈值时才奖励诈唬这样塑形信号才与赢率一致。# reward_shaping.py def shaped_reward(reward, action, bluff_success): # 让奖励落在 [-1, 1] 区间并附加一个轻量过程信号 normalized max(-1.0, min(1.0, reward / 10.0)) if action raise and bluff_success: normalized 0.1 return normalized这段代码最关键的是 reward/10.0 这个除数。不同游戏筹码绝对值差异很大若不归一化一次大额加注就可能让某个动作的回馈值突破神经网络激活函数的饱和区导致后续梯度全部消失。0.1 的过程奖励只作为引导信号不能让它盖过终局胜负否则 AI 会专门刷过程奖励而不顾实际输赢。4.2 自博弈与多目标优化兼顾赢率和策略多样性自博弈不是简单“自己跟自己对打”。如果只让 AI 自己打自己它可能收敛到“石头剪刀布”里的单一策略今天拳头赢剪刀明天布赢拳头后天剪刀赢布没有一个稳定解。德州扑克也类似AI 可能学会一种极端紧或极端松的风格。为了稳定我维护一个“对手池”把最近若干代的智能体快照保存下来每次对局随机挑选一个历史版本作为对手。这本质上是多目标优化一个目标是当前对手池中的平均赢率另一个目标是策略多样性避免坍缩成单一风格。代码实现上可以用一个列表保存每 1000 局的模型权重训练时随机取一个。# opponent_pool.py import random, copy class OpponentPool: def __init__(self, capacity20): self.pool [] self.capacity capacity def update(self, agent): if len(self.pool) self.capacity: self.pool.pop(0) self.pool.append(copy.deepcopy(agent)) def sample_opponent(self): if len(self.pool) 2: return None # 不要总抽最新版否则退化成普通自博弈 return random.choice(self.pool[:-1])“不要总抽最新版”这个小细节很关键。如果对手池容量只有 5AI 很容易记住每个对手的具体风格容量增加到 20 后AI 才被迫学习应对“范围”而不是应对“某个人”。我还会在对手池里掺入随机策略牌手相当于多目标优化里加了一个持续探索的噪声源。这样处理后训练出的模型面对新对手时不容易翻车。如果你发现 AI 在训练中胜率并不高但动作多样性很好说明策略探索足够但“利用”不足。此时应该提高最佳响应网络在自博弈采样中的比例比如把 mode 交替从 50% 改成 70% best。反过来如果胜率高但动作单一就得减少 best 比例让平均策略网络得到更多出场机会。4.3 超参搜索选型网格、贝叶斯、粒子群和元启发式算法深度强化学习算法调参是个十足的玄学。学习率、批量大小、经验回放池大小、目标网络同步间隔、折扣因子这些参数组合起来可能让同一个算法在德州扑克上差出 10% 的胜率。我在做过一遍网格搜索之后就不再硬扫全空间了因为完整的网格搜索会让训练成本爆炸。常见替代方案有三种。一是贝叶斯优化它在小参数量时收敛最快二是粒子群优化PSO它可以在参数量较大时并行探索实现简单三是哈里斯鹰优化、白鲸优化这类元启发式算法适合在训练脚本外做黑盒搜索。我的选择逻辑是如果每次训练要跑 2 小时以上用贝叶斯优化因为它每一轮迭代都能利用历史结果如果训练资源充足可以用粒子群同时开多个训练进程。# search_hyperparams.py import random def particle_swarm_search(eval_fn, param_space, n_particles8, iterations5): particles [] for _ in range(n_particles): particles.append({ params: {k: random.uniform(v[0], v[1]) for k, v in param_space.items()}, best: None }) for it in range(iterations): for p in particles: score eval_fn(p[params]) if p[best] is None or score p[best][0]: p[best] (score, p[params].copy()) best max(particles, keylambda x: x[best][0]) return best[best][1]这段代码只是 PSO 最简版本真正重点是把“每次评估”当成一次完整的德州扑克训练。所以 eval_fn 里要跑 5 万局左右取平均筹码。这种方式适合在离线任务上做自动化调参实际工程里我会先手动搜索到一个可训区域再用粒子群微调。在超参里最容易被低估的是折扣因子 gamma。德州扑克的一手牌不长gamma 设在 0.95 和 0.99 之间比较合适。设得太低会让 AI 只看短期收益导致它只会为了眼前的注池冒险忽略长期筹码价值。设得接近 1.0 又会让价值函数方差变大训练曲线看起来一直抖动。我一般固定 gamma0.99然后把主要精力放在学习率和批量大小上这两个参数对收敛速度影响最直观。5. 德州扑克AI训练常见问题与避坑排查从翻车现场到稳定复现5.1 损失降了但赢率不涨先从评估指标找错现象训练日志里 DQN 的损失一路下降甚至降到接近零但拿出去跟人打或者跟固定策略打胜率只有 20%。原因DQN 的损失是时序差分误差它下降只代表 Q 值预测逐渐“自洽”不代表策略更优。在非平稳环境下如果对手网络也在变Q 值的目标本来就在漂移损失下降很容易造成假象。解决把“每千手平均筹码”作为唯一主指标损失只做辅助参考。评估时固定一个随机策略或规则策略作为对手不要用自博弈对手来评估否则只能说明它能打败上一代自己。5.2 动作单一化和策略坍缩现象训练一段时间后AI 几乎百分之百选择弃牌或者说偶尔全下其他动作全部消失。原因动作熵系数设置太低策略过早陷入某个局部最优。在扑克里一旦某个动作在回报不好时被惩罚网络会把它概率压到 0然后训练就再也探索不到了。解决给动作概率分布加一个熵惩罚项或者把熵系数从 0.01 提高到 0.1。还要检查奖励塑形是否对某些动作有隐性惩罚。我之前让“跟注”在连续两次下注后总收到负奖励AI 就把跟注概率压没了。改成事件级别奖励后恢复正常。5.3 容易被忽略的三个坑随机种子、对手过拟合和跨机复现现象同一天跑三遍相同的训练脚本三个结果差异巨大。原因德州扑克的牌局洗牌、对手池采样、网络初始化都有随机性评估方差又特别大。有些算法论文里的结果换一个随机种子就没了。解决固定环境随机种子最好用同一个 RLCard 环境版本和同一个洗牌种子对比算法时用不同种子跑 3 次取中位数不要取均值。另外评估时至少跑 10000 手牌超过 2000 手的样本方差仍然很惊人。现象AI 在训练时能赢对手池但一换新对手就变回随机水平。原因对手池容量太小AI 记住了每个对手的具体风格而不是习得一般性策略。解决把对手池容量从 5 提升到 20并且在训练中定期加入全新的随机策略对手。如果你发现 AI 在“没见过的人”面前容易翻车就应该增加对手多样性。现象模型文件保存后在别的机器上加载预测结果不一样。原因板级浮点精度、PyTorch 版本差异或没有设评价模式。这个问题不算德州扑克独有但在强化学习里特别容易让人误以为模型没保存好。解决加载后调用 model.eval() 并固定随机种子推理时不要用 Dropout。保存模型时建议连状态字典里的版本号一起记录方便回溯。6. 验证模型好坏的三个手法对弈曲线、动作熵和人工盲测6.1 对弈曲线与固定策略牌手的一万手对局我最习惯的验证方式是每个训练阶段保存模型然后和一个固定规则牌手打一万手。看对弈曲线时重点不是最终胜率而是曲线是否平滑上升。如果曲线在某段突然跳了 20%说明模型只是短暂摸到了一种怪招并不是真的变强。6.2 动作熵监控策略多样性动作熵能反映策略是否过早坍缩。把每个动作的概率分布记录下来计算信息熵。熵值过低说明 AI 只有一种应手很容易被针对熵值过高说明策略还在随机打转。德州扑克一个健康模型的平均动作熵应该稳定在动作空间大小的一半左右。比如三个动作时熵在 0.9 到 1.4 之间比较合理。6.3 人工盲测用真实牌手做最终验收机器指标再漂亮最终还是要回到真实对局里检验。我会找一两个会打德州扑克的朋友盲测把 AI 放在一个动作延迟正常的接口后面不告诉他们对手是 AI。人工盲测能暴露出特征工程的短板比如 AI 在河牌圈面对大注时过于胆小这往往是只靠筹码收益训练出来的常见问题。这套验证流程我用了很久最大的教训是不要迷信某个单独指标。损失、胜率、熵值对照着看才能判断模型是“真的会了”还是“死记硬背了一部分局面”。希望帮到你。本文还有配套的精品资源点击获取
返回列表