资讯动态

Poker AI:基于CFR与MCTS的不完美信息博弈建模实践

发布时间:2026/10/26 3:13:40 来源:尧图企业网站定制
简介本资源是斯坦福大学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的唯一ID0A♠, 12♠, ..., 51K♣手牌直接存为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.92JJ在同面牌上加注概率0.33所有参数来自某高校实验室对10万手职业牌谱的统计拟合。这避免了在线学习的不稳定性也使整个range更新过程完全可复现、可调试。3. CFRMCTS混合策略为什么单用CFR在无限注场景会内存爆炸3.1 标准CFR的致命伤信息集数量随筹码深度指数增长在无限注德州扑克No-Limit Texas Hold’em中CFR算法需为每个“信息集”即玩家在某一时刻所知的所有信息组合存储累计遗憾值regret。一个典型信息集形如(对手位置UTG, 自己手牌AsKs, 公共牌AhKh7c, 历史动作序列[UTG1, 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, parentNone): 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, parentself) 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.pkl20BB、medium_cfr.pkl20~50BB、deep_cfr.pkl50BB运行时根据当前筹码自动加载。这是项目能兼顾泛化性与实时性的关键折中。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_size1.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 你的AIRandom动作完全随机~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的深度控制失效。我曾用此图揪出一个隐藏bugMCTS.max_depth被错误设为常量5导致深筹时搜索过浅——修正后150BB胜率从0.58跃升至0.65。从那以后我每次部署新模型都强制走一遍这三步先用replay_test确保比特级正确再用strength_benchmark看能力断层最后用stack_sensitivity验证核心变量响应。少走一步上线后就可能面对一个“看起来聪明、实则在关键牌局集体失忆”的AI。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑