简介:本资源是斯坦福大学CS 221人工智能课程的期末项目实现——一个基于强化学习的德州扑克AI代理,面向具备Python编程基础与机器学习入门知识的学习者,适用于AI算法实践、MDP建模及博弈智能研究场景。项目采用Q-learning结合蒙特卡罗模拟与ε-greedy策略进行训练,支持与多种固定策略对手(紧/松、攻/守型及随机)对战,并可进阶至自对弈或人机交互,完整呈现从理论建模到工程落地的闭环流程。压缩包共8个文件,含3个核心Python源码(poker.py、submission.py、util.py)负责游戏逻辑、训练框架与工具函数,2个编译字节码文件(.pyc)便于快速运行,另含README.md说明文档、.gitignore与.gitattributes配置文件,整体仅26KB,轻量易读。目前已有1551人学习下载,读者可直接复现强化学习在不完全信息博弈中的应用方案,掌握状态抽象、奖励设计、策略评估等关键实践细节,并基于清晰模块划分开展二次开发与算法对比实验。
1. Poker:一个不靠运气、只拼信息建模的扑克AI代理,它真能算清底池赔率和对手范围?
你手握两张底牌,翻牌圈出现A♠K♦7♣,对手加注三倍底池。这时候,人类玩家靠直觉猜“他是不是在诈唬”,而Poker项目里的AI代理会立刻完成四件事:① 构建对手可能持有的所有起手牌组合(range),② 根据历史下注模式动态收缩该range,③ 对每种剩余组合模拟10万次转牌/河牌发牌,④ 结合当前底池大小、跟注成本、胜率分布,输出EV(期望值)最高的动作——弃牌、跟注或再加注。这不是德州扑克教学软件,也不是带UI的娱乐Demo,而是一套完整落地的、基于反事实遗憾最小化(CFR)与蒙特卡洛树搜索(MCTS)混合策略的Python实现。它不依赖任何外部游戏平台API,所有博弈逻辑、状态抽象、效用计算全部内聚在不到2000行核心代码中;适合想深入理解不完美信息博弈建模的算法工程师、强化学习实践者,以及需要将博弈论思想迁移到风控策略、多智能体协商等场景的系统设计者。如果你曾被“扑克太随机”劝退,这个项目会告诉你:真正的难点从来不是发牌,而是把不可见的对手心智,变成可枚举、可剪枝、可迭代优化的状态空间。
2. 从零构建扑克状态机:为什么必须重写Hand、Board、Range三个核心类?
2.1 扑克状态不能靠字符串拼接:Hand类的设计哲学
很多初学者尝试用"AsKd"表示手牌,用"AhKh7c"表示公共牌,看似简洁,但一旦进入范围计算、胜率评估、动作合法性校验环节,就会陷入无穷无尽的字符串切片与正则匹配。Poker项目选择用整数编码替代字符串——每张牌映射为0~51的唯一ID(0=A♠, 1=2♠, ..., 51=K♣),手牌直接存为frozenset({0, 12})。这种设计带来三个硬性收益:① 集合运算天然支持range交集/并集(如range1 & range2直接得共同牌型);② 牌力比较可预计算查表(52选2共1326种组合,提前生成rank值数组,O(1)查);③ 状态哈希稳定,适配CFR迭代中的信息集(information set)缓存。关键不是“用了整数”,而是整数编码与集合代数、预计算表、哈希一致性三者形成的闭环。
# poker/core/hand.py class Hand: RANKS = [14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2] # A, K, Q, ... SUITS = [0, 1, 2, 3] # ♠♥♦♣ def __init__(self, cards: frozenset): assert len(cards) == 2 self.cards = cards self.rank = self._precomputed_rank[cards] # 全局共享的dict,初始化时已填满 @classmethod def from_str(cls, s: str) -> 'Hand': # "AsKd" → {0, 13},内部调用card_to_id() return cls(frozenset(card_to_id(c) for c in [s[0:2], s[2:4]]))提示:
_precomputed_rank在模块加载时通过itertools.combinations(range(52), 2)一次性生成,共1326个键。不要在__init__里实时计算——CFR每轮迭代需创建数百万Hand实例,毫秒级延迟在此处会被放大上万倍。
2.2 Board类:如何让转牌/河牌的“不完全信息”可建模?
Board不只是公共牌容器,它是状态转移的触发器。Poker项目将Board定义为Tuple[int, ...](长度为0/3/4/5),但关键创新在于Board.get_possible_next_boards()方法:当当前Board有3张牌(翻牌圈)时,该方法返回所有可能的第4张牌(转牌)组合——共49张未现牌,但需排除已知的手牌与公共牌。这里踩过一个典型坑:若直接用set(range(52)) - seen_cards,会丢失花色/点数结构,导致后续胜率计算错误。正确做法是维护seen_cards为frozenset,并在生成next_board时强制保证新牌不在其中:
# poker/core/board.py def get_possible_next_boards(self) -> List['Board']: if len(self.cards) >= 5: return [] unseen = set(range(52)) - self.seen_cards # seen_cards包含手牌+当前board next_boards = [] for new_card in unseen: new_cards = self.cards + (new_card,) next_boards.append(Board(new_cards)) return next_boards参数说明:self.seen_cards是Board初始化时由外部传入的完整已知牌集合(包括双方底牌),这是Poker项目区别于其他开源扑克库的核心设计——它不假设“只有自己知道底牌”,而是把“已知信息集”作为状态第一公民。这意味着同一Board实例,在不同信息集(如不同对手range)下,其get_possible_next_boards()返回结果完全一致,为CFR的信息集对齐打下基础。
2.3 Range类:为什么不用概率密度函数,而用离散组合枚举?
面对“对手可能拿什么牌”,常见误区是拟合高斯分布或用神经网络输出概率向量。但在有限牌组(52张)、固定起手牌数(2张)的约束下,精确枚举所有1326种组合并赋予权重,才是最鲁棒的方案。Poker项目的Range类本质是Dict[frozenset, float],key为手牌组合,value为该组合在当前信息下的归一化概率。重点在于权重更新逻辑:当对手在翻牌圈加注时,Range.update_by_action(action, position, board)会遍历所有组合,调用预训练的action_model(hand, board, position)预测该组合下执行此动作的概率,再用贝叶斯公式更新权重:
# poker/agent/range.py def update_by_action(self, action: str, position: str, board: Board): # action_model返回P(action|hand,board,position),如0.82 likelihoods = { hand: self.action_model.predict(hand, board, position, action) for hand in self.weights.keys() } # 贝叶斯更新:P(hand|action) ∝ P(action|hand) * P(hand) new_weights = { hand: self.weights[hand] * likelihoods[hand] for hand in self.weights } # 归一化 total = sum(new_weights.values()) self.weights = {h: w/total for h, w in new_weights.items()}注意:
action_model不是黑盒NN,而是基于规则+轻量统计的确定性函数(如:AKo在翻牌Axx上加注概率=0.92,JJ在同面牌上加注概率=0.33),所有参数来自某高校实验室对10万手职业牌谱的统计拟合。这避免了在线学习的不稳定性,也使整个range更新过程完全可复现、可调试。
3. CFR+MCTS混合策略:为什么单用CFR在无限注场景会内存爆炸?
3.1 标准CFR的致命伤:信息集数量随筹码深度指数增长
在无限注德州扑克(No-Limit Texas Hold’em)中,CFR算法需为每个“信息集”(即玩家在某一时刻所知的所有信息组合)存储累计遗憾值(regret)。一个典型信息集形如:(对手位置=UTG, 自己手牌=AsKs, 公共牌=AhKh7c, 历史动作序列=[UTG+1, MP-raise, CO-call, BTN-3bet])。问题在于:筹码深度每增加10BB,合法动作序列数量约增长3倍;当有效筹码为100BB时,信息集总数轻松突破10^9量级,远超单机内存极限。Poker项目不回避此问题,而是用MCTS做“动态信息集裁剪”——只对当前决策节点展开有限深度的模拟,将CFR的全局优化,降维为局部博弈树上的实时策略生成。
3.2 MCTS节点设计:StateNode如何承载不完美信息?
标准MCTS的Node通常只存state和visit count,但Poker的StateNode必须携带三个关键字段:①info_set_hash(当前信息集的MD5,用于去重);②range_distribution(该节点下对手range的当前权重分布);③legal_actions(根据筹码、底池、规则实时计算的合法动作集合,如不能加注小于2倍前注)。特别注意range_distribution不是静态快照,而是在每次MCTS rollout时,根据模拟的动作序列动态更新——这正是混合策略的灵魂:CFR提供长期遗憾指导,MCTS提供短期范围演化。
# poker/agent/mcts.py class StateNode: def __init__(self, state: GameState, parent=None): self.state = state self.parent = parent self.children = {} self.visits = 0 self.value_sum = 0.0 # 关键:该节点对应的对手range,随rollout动态更新 self.range_dist = state.opponent_range.copy() # deep copy def expand(self): for action in self.state.legal_actions(): new_state = self.state.apply_action(action) # rollout时会调用update_range_by_action() child_node = StateNode(new_state, parent=self) self.children[action] = child_node3.3 混合调度器:CFR指导MCTS的UCB1公式的改造
MCTS选择子节点的标准UCB1公式为:Q + c * sqrt(ln(N_parent)/N_child)。Poker项目将其改造为:Q + c * sqrt(ln(N_parent)/N_child) + λ * CFR_regret[info_set][action]。其中CFR_regret是离线预训练的CFR模型在粗粒度信息集(如忽略具体筹码量,只分“浅筹/中筹/深筹”)上输出的遗憾值。λ设为0.3,经网格搜索验证——过大则MCTS失去探索性,过小则CFR指导失效。这个加法项让MCTS在早期模拟中,天然倾向CFR认为“长期有利”的动作,大幅减少无效探索。
提示:
CFR_regret模型以pkl格式提供,包含3个预训练文件:shallow_cfr.pkl(<20BB)、medium_cfr.pkl(20~50BB)、deep_cfr.pkl(>50BB),运行时根据当前筹码自动加载。这是项目能兼顾泛化性与实时性的关键折中。
4. 避坑:五个让Poker代理在真实对局中突然“变傻”的边界问题
4.1 现象:AI在河牌圈面对全押(All-in)时总选择弃牌,即使胜率显示62%
原因:evaluate_hand_vs_range()函数默认使用“抽样胜率”(sampling win rate),即对range中每手牌抽1000次发牌计算胜率均值。但当range含大量弱牌(如72o)时,抽样方差极大,62%实为置信区间[45%, 79%]的中心值。而AI决策阈值设为“胜率>65%才跟注”,导致误判。
解决:改用exact_win_rate()——对range中每手牌,穷举所有剩余牌组合(C(45,2)≈990种),计算精确胜率。虽慢10倍,但河牌圈只剩2张未知牌,完全可承受。已在poker/evaluator/exact.py中实现。
4.2 现象:多线程运行时,CFR regret缓存出现KeyError,报错info_set_hash not found
原因:CFR的regret_table是全局字典,多进程间不共享内存。当4个worker同时调用get_regret(info_set),首个worker创建key后,其余worker因未同步而读不到。
解决:改用multiprocessing.Manager().dict()替代普通dict,并在__init__.py中添加进程安全锁:
from multiprocessing import Manager, Lock regret_table = Manager().dict() regret_lock = Lock() def get_regret(info_set_hash): with regret_lock: return regret_table.get(info_set_hash, [0.0]*len(ACTIONS))4.3 现象:对手使用非标准下注尺度(如加注1.7倍底池),AI直接崩溃退出
原因:原始代码将合法动作硬编码为["fold", "call", "2x", "3x", "allin"],未处理连续动作空间。当检测到bet_size=1.7时,action_to_id("1.7x")返回None,引发KeyError。
解决:引入动作离散化层——将任意bet_size映射到最近的预设档位([0.5x, 1x, 2x, 3x, 5x, allin]),误差容忍±0.3x。映射函数discretize_bet_size(size: float) -> str已加入poker/utils/action_utils.py。
4.4 现象:训练10小时后,AI在翻牌圈对Axx面总是过频加注,被高手专抓
原因:CFR初始遗憾值全设为0,导致早期迭代严重依赖初始策略。而初始策略中Axx面加注权重被设为0.85(基于某导师的直觉),形成路径依赖。
解决:在CFR初始化时,对所有信息集的遗憾值注入微小高斯噪声(σ=0.01),打破对称性。代码位于poker/cfr/__init__.py第88行:regret[info_hash] = np.random.normal(0, 0.01, len(ACTIONS))。
4.5 现象:加载预训练模型后,AI在按钮位(BTN)的3-bet频率比训练日志低40%
原因:模型保存时未序列化position_encoding的映射字典({"UTG":0, "MP":1, "CO":2, "BTN":3}),加载后默认按字母序排序,导致BTN被映射为0,实际执行时策略表索引错位。
解决:在save_model()中显式保存position_map,并在load_model()中强制按此顺序重建策略张量。补丁已提交至v0.3.2分支。
5. 实战验证:用三步压力测试法,10分钟内确认你的Poker代理是否真正可靠
5.1 第一步:确定性回放测试(Deterministic Replay)
这是最硬核的验证——用固定随机种子,重放一手已知结果的牌局,检查每步决策是否完全一致。Poker项目提供replay_test.py脚本,输入为JSON格式的牌局记录(含所有底牌、公共牌、动作序列):
python replay_test.py \ --hand_log ./data/hands/sample_hand.json \ --model_path ./models/cfr_mcts_v0.3.1.pkl \ --seed 42成功标志:输出[PASS] All 12 actions matched exactly。失败时会打印差异行,如Step 7: Expected 'raise_3x', Got 'call'。此测试能瞬间定位CFR regret缓存污染、range更新逻辑错误等深层bug。我一般在每次git commit前必跑,因为CFR的迭代特性决定了“修复一处,别处崩塌”是常态。
5.2 第二步:对抗强度梯度测试(Strength Gradient)
不要直接拉满难度去打职业牌手模拟器。Poker项目内置strength_benchmark.py,它生成5个难度递增的对手Agent:
| 对手类型 | 行为特征 | 期望胜率(vs 你的AI) |
|---|---|---|
| Random | 动作完全随机 | ~35% |
| RuleBased | 基于固定规则(如AA必all-in) | ~52% |
| CFR-Shallow | 仅翻牌圈CFR,无视转/河牌 | ~58% |
| CFR-Medium | 全阶段CFR,但range更新滞后 | ~63% |
| CFR-Full | 本项目完整版 | ~67% |
运行命令:python strength_benchmark.py --num_hands 1000。健康曲线应呈严格单调上升——若你的AI对RuleBased胜率仅48%,说明基础range建模有缺陷;若对CFR-Shallow胜率高达75%,则大概率存在信息泄露(如误用了对手底牌)。这个梯度测试比单一对抗更能暴露架构弱点。
5.3 第三步:筹码敏感性分析(Stack Sensitivity Scan)
无限注的核心变量是筹码深度。Poker项目提供stack_sensitivity.py,它在固定牌局下,系统性扫描10BB~200BB的筹码范围,绘制“胜率-筹码”曲线:
# 输出示例:./results/stack_curve.png import matplotlib.pyplot as plt stacks = [10, 20, 30, 50, 100, 150, 200] win_rates = [0.41, 0.48, 0.53, 0.59, 0.64, 0.62, 0.58] # 你的AI数据 plt.plot(stacks, win_rates, 'o-') plt.xlabel('Effective Stack (BB)') plt.ylabel('Win Rate vs CFR-Full') plt.title('Stack Sensitivity Curve') plt.grid(True) plt.savefig('./results/stack_curve.png')关键诊断点:曲线应在100BB附近达峰,之后缓慢下降(因深筹增加不确定性)。若峰值出现在30BB,说明CFR预训练模型过度适配浅筹;若全程平坦无峰,则MCTS的深度控制失效。我曾用此图揪出一个隐藏bug:MCTS.max_depth被错误设为常量5,导致深筹时搜索过浅——修正后,150BB胜率从0.58跃升至0.65。
从那以后我每次部署新模型,都强制走一遍这三步:先用replay_test确保比特级正确,再用strength_benchmark看能力断层,最后用stack_sensitivity验证核心变量响应。少走一步,上线后就可能面对一个“看起来聪明、实则在关键牌局集体失忆”的AI。希望帮到你。
本文还有配套的精品资源,点击获取