简介:这是一份基于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 Hold'em 讲起,让新手能跟着复现,让熟手能跳到最后看超参和避坑。
2. 深度强化学习解决德州扑克的关键:从不完全信息博弈到可训练的智能体
2.1 德州扑克的博弈复杂度在哪儿:隐藏牌、下注轮和透支筹码
德州扑克的核心难点,并不是“牌型组合太多”,而是每一手牌都存在信息不对称:自己知道两张底牌,对手只知道公共牌和自己下注的节奏。深度强化学习处理这种问题,不能照搬 Atari 那套只靠像素和奖励的做法,因为它没有显式的对手建模,很容易让 AI 只学会针对当前对手的固定打法。
更麻烦的是下注轮结构。预翻牌、翻牌、转牌、河牌四轮下注,每一轮都有加注和再加注,筹码深度直接影响策略。同一个牌手在浅筹码时可能玩得紧,深筹码时又愿意看翻牌。如果 AI 的输入特征里没有筹码深度和下注轮信息,训练出来的策略就会显得“人格分裂”。
所以我在项目里会刻意把游戏抽象成“回合制部分可观察马尔可夫决策过程”(POMDP)。状态是当前玩家的手牌、公共牌、注池筹码、剩余筹码和对手本局历史动作;动作是弃牌、跟注、加注;奖励是最终获得的筹码增量。这样一来,深度强化学习才能真正接手德州扑克。
2.2 算法选型:DQN、PPO 与 NFSP 的边界
很多人问:“深度强化学习做德州扑克,是不是用 DQN 就行?”我的答案是:单人局可以用 DQN,两人对战很快会翻车,因为对手的策略也在变,环境对智能体来说是“非平稳”的。DQN 假设环境固定,一旦你的 AI 变强了,对手池却没变,它会过拟合到陈旧打法上。
PPO 这类策略梯度算法更稳定,但同样要面对非平稳问题。常见做法是引入自博弈:让 AI 跟自己的历史版本打,而不是跟固定规则牌手打。这又带来一个典型问题:只跟当前版本打会导致策略震荡,今天赢昨天,明天输今天。所以我在简化版项目里推荐 NFSP(Neural Fictitious Self-Play),它是深度强化学习在德州扑克上最有代表性的方案之一。
NFSP 的核心思想是把神经网络分成两支:一支是“最佳响应网络”,用 DQN 去学如何打败当前对手;另一支是“平均策略网络”,把历史上多个最佳响应策略做平均,作为下一轮自博弈的对手。平均的含义很关键,它吸收了历史各种风格,避免策略只盯住某个版本打。DeepStack 和很多知名扑克 AI 都用类似思路,只是底层换成了反事实遗憾最小化(CFR)来提供更精确的指导。
在这条路径上,很多人会问:既然 DeepStack 用 CFR 取代深度强化学习,那为什么不直接做 CFR?原因很现实:CFR 需要对博弈树做多次迭代,完整德州扑克的状态空间大到必须用蒙特卡洛抽样,工程复杂度比深度强化学习高一个量级。如果你只是为了研究深度强化学习在博弈中的表现,用 NFSP 这种方案更容易在 Python 里实现和可视化;如果目标是追平职业牌手,那应该去读 DeepStack 和 Pluribus 的实现,把 CFR 和深度网络结合起来。
2.3 Python 最小环境搭建:一个可复现的 Leduc Hold'em 训练骨架
不建议一上来就写完整德州扑克规则引擎,那会和算法优化抢时间。我一般用 Leduc Hold'em 作为基准环境:牌组只有 J、Q、K 各两张,每人两张底牌,翻牌只发一张公共牌,两轮下注,加注次数受限。这个环境规模小但保留了诈唬、跟注、加注这些博弈要素,适合验证算法。
# minimal_poker_env.py import random class LeducPokerEnv: """ 简化版 Leduc Hold'em,重点暴露 reset/step/obs/action 接口 真实项目的规则引擎建议直接用 RLCard 的内置实现 """ def __init__(self, n_raise_limit=2): 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 Hold'em 来避免自己写的规则有漏洞。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, lr=3e-4, replay_size=200_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(maxlen=replay_size) self.replay_sl = deque(maxlen=replay_size) self.lr = lr self._sync_target() def act(self, obs, mode='average'): 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, episodes=200_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,目标网络同步步数 C=1000。这些数值不是拍脑袋,它们是许多扑克 AI 论文里的常用量级。但注意,当你把单人有限注换成多人无限注时,回放池需要加大到 500000 以上,批量大小也可以酌情调整,因为动作空间变大,单次更新需要更多样本。
3.3 评估曲线:以“每千手平均筹码”代替损失值
深度强化学习训练日志里最误导人的就是 loss 下降。在扑克里,loss 下降可能只代表 Q 值预测趋于稳定,不代表策略会赢。我自己的评估函数只关心一个指标:每千手平均筹码收益。
# evaluate.py def evaluate(agent, n_hands=10000): total_chips = 0.0 env = LeducPokerEnv() for _ in range(n_hands): obs = env.reset() done = False while not done: action = agent.act(obs, mode='best') 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, capacity=20): 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])“不要总抽最新版”这个小细节很关键。如果对手池容量只有 5,AI 很容易记住每个对手的具体风格;容量增加到 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_particles=8, iterations=5): 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, key=lambda x: x['best'][0]) return best['best'][1]这段代码只是 PSO 最简版本,真正重点是把“每次评估”当成一次完整的德州扑克训练。所以 eval_fn 里要跑 5 万局左右,取平均筹码。这种方式适合在离线任务上做自动化调参,实际工程里我会先手动搜索到一个可训区域,再用粒子群微调。
在超参里,最容易被低估的是折扣因子 gamma。德州扑克的一手牌不长,gamma 设在 0.95 和 0.99 之间比较合适。设得太低会让 AI 只看短期收益,导致它只会为了眼前的注池冒险,忽略长期筹码价值。设得接近 1.0 又会让价值函数方差变大,训练曲线看起来一直抖动。我一般固定 gamma=0.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 在河牌圈面对大注时过于胆小,这往往是只靠筹码收益训练出来的常见问题。
这套验证流程我用了很久,最大的教训是不要迷信某个单独指标。损失、胜率、熵值对照着看,才能判断模型是“真的会了”还是“死记硬背了一部分局面”。希望帮到你。
本文还有配套的精品资源,点击获取