简介:这是一份面向人工智能与强化学习学习者的德州扑克 AI 代理实现项目,源自斯坦福大学 CS 221 课程最终项目。项目将扑克博弈建模为马尔可夫决策过程,结合 Q-Learning、蒙特卡罗模拟与 epsilon-greedy 策略训练智能体,先与紧攻击型、松散攻击型、紧被动型、松散被动型及随机对手对战学习,再实现自我对弈或与人类玩家对战,适合想深入理解 MDP 与强化学习落地的中高级开发者参考。资源包共 8 个文件,约 26KB,以 Python 源码为主,包含 3 个 py 脚本、2 个 pyc 编译文件,以及 1 个 md 说明文档和 gitignore、gitattributes 等工程配置文件,结构紧凑、便于直接运行与二次修改。目前已有 1551 人学习下载。通过阅读源码,读者可掌握状态抽象、动作空间设计、奖励函数设定与 Q 表更新等关键环节,并借鉴蒙特卡罗模拟在不确定信息博弈中的采样思路,为自身强化学习项目提供可复用的实现范式与排错参考。
1. 从零拆解一个扑克 AI 代理:Python 实现里到底藏着什么
很多人第一次听到“扑克 AI 代理”,脑子里浮现的是赌场里那种能算牌的机器,或者某个能横扫牌桌的玄学模型。但真正拆开来看,它其实是一个典型的不完全信息博弈问题:你看不到对手的底牌,对手也看不到你的,双方只能通过下注动作来推断彼此的范围。这和象棋、围棋那种双方信息完全透明的游戏有本质区别——后者可以用搜索树暴力推演,前者不行,你必须建模“对手在想什么”。
这份资源给出的是一套用 Python 实现的扑克 AI 代理,核心思路是围绕状态表示、动作空间、策略网络和对手建模四个模块展开。它适合两类人:一类是想学博弈论与强化学习结合落地的开发者,另一类是想拿一个完整可跑的项目来理解“不完全信息下怎么做决策”的工程师。你不需要先成为德扑高手,但得能看懂 Python 类结构和基本的概率计算。下面我从代码结构一路拆到参数调优,把能复现的细节都摆出来。
2. 拆开代理的骨架:状态编码、动作空间与决策循环
2.1 状态编码:把牌桌信息压成网络能吃的张量
扑克 AI 的第一个难点不是模型多大,而是怎么把一手牌、公共牌、筹码、位置、历史动作这些异构信息塞进一个固定维度的向量。常见做法是分通道编码:手牌用 52 维的 one-hot 表示,公共牌同理,筹码和底池做归一化,位置和动作历史用 embedding 或者直接拼 one-hot。
我一般会按下面这个结构来组织状态编码器,代码不复杂,但每个维度的含义必须写清楚,不然后面调参就是黑匣子。
import numpy as np class StateEncoder: """把一局牌的状态编码成固定长度向量""" def __init__(self): self.card_dim = 52 # 一副牌 52 张,one-hot self.action_dim = 10 # 动作历史窗口,最近 10 步 self.scalar_dim = 6 # 底池、己方筹码、对手筹码、位置、轮次、有效下注 def encode(self, hole_cards, community_cards, pot, my_stack, opp_stack, position, street, last_actions): # 手牌 one-hot:两张牌置 1 hole = np.zeros(self.card_dim, dtype=np.float32) for c in hole_cards: hole[self._card_index(c)] = 1.0 # 公共牌 one-hot:翻牌 3 张、转牌 1 张、河牌 1 张 board = np.zeros(self.card_dim, dtype=np.float32) for c in community_cards: board[self._card_index(c)] = 1.0 # 标量归一化:底池和筹码除以初始筹码,位置和轮次除以最大值 scalars = np.array([ pot / 200.0, my_stack / 200.0, opp_stack / 200.0, position / 6.0, street / 3.0, last_actions[-1] / 5.0 if last_actions else 0.0 ], dtype=np.float32) # 动作历史:取最近 10 步,不足补零 hist = np.zeros(self.action_dim, dtype=np.float32) for i, a in enumerate(last_actions[-self.action_dim:]): hist[i] = a / 5.0 return np.concatenate([hole, board, scalars, hist]) def _card_index(self, card): # card 格式如 'As'、'Kh',转成 0-51 rank_map = {r: i for i, r in enumerate('23456789TJQKA')} suit_map = {s: i for i, s in enumerate('shdc')} return rank_map[card[0]] * 4 + suit_map[card[1]]这段代码里最容易被忽略的是归一化。底池和筹码如果不除以初始筹码,数值可能到几千,而 one-hot 只有 0/1,两者拼在一起送进网络,梯度会被大数值主导,训练时 loss 震荡得厉害。另一个坑是动作历史的窗口长度:设 10 是经验值,太短记不住对手的加注节奏,太长则稀疏且增加输入维度。位置编码用 0 到 6 表示枪口到按钮,这个顺序不能乱,否则模型学到的位置优势和实际相反。
2.2 动作空间:为什么不是简单的“跟注或加注”
扑克的动作空间看起来只有弃牌、跟注、加注三种,但加注的尺度是连续的。如果直接把加注金额作为连续动作输出,策略网络很难收敛,因为不同底池下的合理加注范围差异巨大。常见做法是离散化:把加注分成底池的 0.5 倍、0.75 倍、1 倍、1.5 倍、2 倍,再加上全下,一共 6 档,加上弃牌和跟注,总共 8 个离散动作。
class ActionSpace: """离散化加注尺度,输出 8 个动作""" def __init__(self, pot_fractions=(0.5, 0.75, 1.0, 1.5, 2.0)): self.fractions = pot_fractions self.num_actions = 2 + len(pot_fractions) + 1 # 弃牌、跟注、5 档加注、全下 def get_legal_actions(self, pot, my_stack, to_call): """返回当前合法动作的掩码和对应金额""" actions = [] # 0: 弃牌 actions.append(('fold', 0)) # 1: 跟注 call_amount = min(to_call, my_stack) actions.append(('call', call_amount)) # 2-6: 按底池比例加注 for f in self.fractions: raise_amount = int(pot * f) total = to_call + raise_amount if total < my_stack: actions.append(('raise', total)) else: actions.append(('allin', my_stack)) # 7: 全下 actions.append(('allin', my_stack)) return actions def action_mask(self, legal_actions): """生成掩码,非法动作为 0""" mask = np.zeros(self.num_actions, dtype=np.float32) for i, (name, amount) in enumerate(legal_actions): if i < self.num_actions: mask[i] = 1.0 return mask这里的关键参数是pot_fractions。我试过只留 0.5 和 1.0 两档,模型学出来的策略非常死板,翻牌前只会迷你加注,容易被对手读死。加到 5 档之后,策略明显丰富,但训练时间也上去了。另一个细节是全下动作要单独处理:当加注金额超过剩余筹码时,必须截断成全下,否则会出现筹码为负的非法状态。动作掩码在推理时一定要用,不然模型可能输出一个筹码不够的加注,直接翻车。
2.3 决策循环:从发牌到动作输出的完整链路
把状态编码和动作空间串起来,就是一个完整的决策循环。下面这段是代理的主循环,每到一个决策点就调用一次。
class PokerAgent: def __init__(self, model, encoder, action_space): self.model = model self.encoder = encoder self.action_space = action_space def act(self, game_state): # 1. 编码状态 state_vec = self.encoder.encode( game_state['hole_cards'], game_state['community_cards'], game_state['pot'], game_state['my_stack'], game_state['opp_stack'], game_state['position'], game_state['street'], game_state['action_history'] ) # 2. 获取合法动作 legal = self.action_space.get_legal_actions( game_state['pot'], game_state['my_stack'], game_state['to_call'] ) mask = self.action_space.action_mask(legal) # 3. 模型前向,输出动作概率 logits = self.model.predict(state_vec[np.newaxis, :]) masked_logits = logits + (1 - mask) * (-1e9) # 非法动作置极大负值 probs = softmax(masked_logits) # 4. 按概率采样或取 argmax action_idx = np.random.choice(len(probs), p=probs) return legal[action_idx]逻辑说明:第 1 步编码状态,注意game_state里的字段名要和编码器参数一一对应,少一个就会报 KeyError。第 2 步拿合法动作,这一步不能省,因为不同轮次、不同筹码深度下合法动作集合是变化的。第 3 步的掩码操作是推理阶段必须做的,训练时可以在 loss 里加 mask,但推理时如果忘了加,模型可能输出一个弃牌以外的非法动作。第 4 步采样还是 argmax 取决于场景:训练时用采样增加探索,实战时用 argmax 保证稳定。我一般会在前 1000 手用采样,之后切 argmax。
3. 训练策略网络:用 CFR 还是深度强化学习
3.1 CFR 与深度 RL 的选型对比
扑克 AI 的训练方法主要有两条路:反事实遗憾最小化(CFR)和深度强化学习(DQN/PPO)。CFR 是博弈论里的经典方法,理论上能收敛到纳什均衡,但它的计算量随状态空间指数增长,适合抽象后的有限状态。深度 RL 则用神经网络拟合价值函数,能处理更大状态空间,但训练不稳定,容易过拟合到对手的特定策略。
我一般会这样选:如果目标是单挑无限注德州扑克,且算力有限,先用 CFR 做抽象,把牌力分成若干桶,再在抽象博弈上求解。如果目标是多桌、多对手,且能拿到大量对局数据,用深度 RL 更合适。这份资源里的实现是深度 RL 路线,用 PPO 做策略优化,因为它的动作空间是离散的,PPO 的 clip 机制比 DQN 的 Q 值估计更稳。
3.2 PPO 训练脚本与关键超参
下面是一个简化的 PPO 训练循环,重点看超参设置和优势估计。
import torch import torch.nn as nn import torch.optim as optim class PolicyNet(nn.Module): def __init__(self, input_dim, hidden_dim, action_dim): super().__init__() self.fc = nn.Sequential( nn.Linear(input_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, action_dim) ) def forward(self, x): return self.fc(x) def train_ppo(agent, env, epochs=1000, batch_size=256, clip_eps=0.2, lr=3e-4, gamma=0.99, lam=0.95): optimizer = optim.Adam(agent.model.parameters(), lr=lr) for epoch in range(epochs): states, actions, rewards, log_probs, values = [], [], [], [], [] # 采样一批对局 for _ in range(batch_size): traj = env.rollout(agent) states.extend(traj['states']) actions.extend(traj['actions']) rewards.extend(traj['rewards']) log_probs.extend(traj['log_probs']) values.extend(traj['values']) # 计算 GAE 优势 advantages = compute_gae(rewards, values, gamma, lam) returns = advantages + values # PPO 更新 for _ in range(4): # 每批数据重复利用 4 次 new_logits = agent.model(torch.FloatTensor(states)) new_log_probs = log_softmax(new_logits, actions) ratio = torch.exp(new_log_probs - torch.FloatTensor(log_probs)) surr1 = ratio * torch.FloatTensor(advantages) surr2 = torch.clamp(ratio, 1 - clip_eps, 1 + clip_eps) * torch.FloatTensor(advantages) policy_loss = -torch.min(surr1, surr2).mean() value_loss = nn.MSELoss()(agent.model(torch.FloatTensor(states)), torch.FloatTensor(returns)) loss = policy_loss + 0.5 * value_loss optimizer.zero_grad() loss.backward() optimizer.step()参数说明:clip_eps=0.2是 PPO 的默认值,控制新旧策略的偏离程度,调大到 0.3 会让更新更激进但容易崩,调小到 0.1 则学习慢。gamma=0.99是折扣因子,扑克一局通常几十步,0.99 足够看到长期收益。lam=0.95是 GAE 的平滑参数,越高方差越大偏差越小。lr=3e-4是 Adam 的常见起点,如果 loss 震荡就降到 1e-4。batch_size=256是每轮采样的对局数,太小梯度噪声大,太大则更新频率低。我一般会先跑 100 轮看 reward 曲线,如果一直不涨,先检查状态编码有没有归一化,再检查动作掩码有没有漏。
3.3 对手建模:用历史动作估计范围
纯策略网络只学自己的最优策略,但扑克是对抗性的,你必须根据对手的动作调整。常见做法是维护一个对手范围表:根据对手在每个动作节点的频率,反推他手里的牌力分布。比如对手在翻牌前加注,他的范围可能是强牌加部分诈唬;如果他在转牌继续下注,范围进一步收窄。
class OpponentModel: def __init__(self): self.range_table = {} # 节点 -> 手牌概率分布 def update(self, node, action, hand_strength): """根据对手动作更新范围""" if node not in self.range_table: self.range_table[node] = np.ones(1326) / 1326 # 1326 种起手牌组合 # 贝叶斯更新:动作概率乘以先验 likelihood = self._action_likelihood(action, hand_strength) self.range_table[node] *= likelihood self.range_table[node] /= self.range_table[node].sum() def _action_likelihood(self, action, strength): # 强牌更可能加注,弱牌更可能弃牌 if action == 'raise': return 0.8 if strength > 0.7 else 0.2 elif action == 'call': return 0.5 else: return 0.1 if strength > 0.7 else 0.9这个模型的精度取决于_action_likelihood的设计。我一般会先用一个粗略的阈值跑起来,然后根据实际对局数据做校准。注意范围表的大小是 1326,对应 52 张牌里选 2 张的组合数,不要写成 52×51,那样会重复计算。
4. 避坑与排查:训练不收敛、动作非法、胜率虚高
4.1 现象:loss 震荡不下降,reward 长期为负
原因:最常见的是状态编码里混入了未归一化的大数值,比如底池直接用了原始筹码数,而 one-hot 是 0/1,两者量级差几百倍,网络梯度被大数值主导。另一个原因是动作掩码在训练时没加,模型输出了非法动作,环境返回负奖励,策略被带偏。
解决:把所有连续变量除以一个固定基准(初始筹码或最大底池),确保输入向量每个维度都在 0 到 1 附近。训练时在 loss 里加动作掩码,非法动作的 logit 置 -1e9。跑之前先打印一批状态向量,看有没有超过 10 的数值。
4.2 现象:推理时输出“加注 300”但筹码只有 200
原因:动作空间离散化时,加注金额是按底池比例算的,但没和剩余筹码比较。当底池很大、筹码很少时,算出来的加注额会超过筹码。
解决:在get_legal_actions里加判断,如果total >= my_stack,就把动作改成全下,金额设为my_stack。同时动作掩码要对应更新,确保全下动作合法。
4.3 现象:训练胜率 70%,实战一局就输
原因:过拟合到训练对手的特定策略。训练时对手可能是固定风格的脚本,模型学会了针对它的打法,但实战对手风格不同,策略失效。
解决:训练时用对手池,每轮随机从多个不同风格的脚本里选一个作为对手,包括紧凶、松凶、跟注站、诈唬狂。对手池越大,策略泛化越好。我一般会准备 5 到 10 个脚本对手,每 100 轮换一次。
4.4 现象:CFR 抽象后策略和实际牌力脱节
原因:牌力分桶太粗,比如把 AA 和 22 分到同一桶,策略会把 22 当 AA 打。
解决:分桶时用手牌胜率而不是简单的高牌点数。常见做法是用蒙特卡洛模拟每种起手牌对随机手牌的胜率,然后按胜率分位数分桶。桶数一般 10 到 20 个,太少丢信息,太多则抽象失去意义。
4.5 现象:训练到后期 reward 突然崩掉
原因:PPO 的 clip 参数太小或学习率太高,策略更新步子太大,直接跳出合理区域。也可能是优势估计的方差太大,导致梯度方向错误。
解决:把clip_eps从 0.2 降到 0.1,学习率从 3e-4 降到 1e-4,同时增大 batch_size 到 512。如果还崩,检查 GAE 的lam是不是设成了 1.0,那样方差会很大,改成 0.95。
5. 进阶技巧:用抽象加速训练与蒙特卡洛胜率验证
5.1 牌力抽象:把 1326 种组合压到 20 个桶
深度 RL 直接处理 1326 种起手牌组合,状态空间太大,训练慢且容易过拟合。常见做法是先做牌力抽象:用蒙特卡洛模拟每种组合对随机手牌的胜率,然后按胜率分位数分成 20 个桶。这样输入维度从 1326 降到 20,训练速度能快一个数量级。
def build_abstraction(num_buckets=20, samples=10000): """用蒙特卡洛胜率做牌力分桶""" combos = list(itertools.combinations(range(52), 2)) win_rates = [] for combo in combos: wins = 0 for _ in range(samples): opp = random.sample([c for c in range(52) if c not in combo], 2) board = random.sample([c for c in range(52) if c not in combo and c not in opp], 5) if evaluate(combo + board) > evaluate(opp + board): wins += 1 win_rates.append(wins / samples) # 按胜率排序后等频分桶 sorted_idx = np.argsort(win_rates) bucket_map = {} for rank, idx in enumerate(sorted_idx): bucket_map[combos[idx]] = rank * num_buckets // len(combos) return bucket_map参数说明:samples=10000是每种组合的模拟次数,10000 次后胜率的标准误大约 0.5%,足够分桶。num_buckets=20是经验值,我试过 10 个桶,AA 和 KK 会分到一起,策略区分度不够;30 个桶则抽象收益递减,训练时间反而增加。分桶后,状态编码里的手牌 one-hot 改成桶的 one-hot,维度从 52 降到 20。
5.2 蒙特卡洛胜率验证:别只看 reward 曲线
训练时 reward 上涨不代表策略真的变强,可能只是对手变弱了。我一般会每 500 轮做一次蒙特卡洛胜率验证:让当前策略和一组固定基准策略(比如随机策略、紧凶策略)各打 1000 局,统计胜率。如果对随机策略胜率低于 60%,说明策略还没入门;对紧凶策略胜率低于 45%,说明对抗性不够。
def evaluate_against_baseline(agent, baseline, num_hands=1000): wins = 0 for _ in range(num_hands): env = PokerEnv(opponent=baseline) result = env.play_hand(agent) if result > 0: wins += 1 return wins / num_hands # 每 500 轮调用一次 if epoch % 500 == 0: wr_random = evaluate_against_baseline(agent, RandomPolicy()) wr_tight = evaluate_against_baseline(agent, TightAggressivePolicy()) print(f"Epoch {epoch}: vs Random {wr_random:.2%}, vs Tight {wr_tight:.2%}")这个验证方法比看 reward 曲线靠谱得多,因为 reward 受对手风格影响大,而固定基准策略是稳定的。我一般会设三个基准:随机、紧凶、松凶,三个胜率都达标才算训练合格。从那以后我每次训练新模型,都强制走一遍这个验证流程,不再只看 loss 和 reward。希望帮到你。
本文还有配套的精品资源,点击获取