简介:这份资源围绕基于Python深度强化学习的德州扑克AI算法优化展开,核心agent位于“实验环境/agents/DeepCFRagent3.py”,由DeepCFR改进而来。资源在Limit与NoLimit Leduc Holdem Poker上用exploitability衡量与纳什均衡的距离,并与CFR、CFR+、MCCFR、DeepCFR等主流算法对比;在规模较大的Limit Holdem Poker中使用对战RandomAgent的reward评估,兼顾理论收敛质量与实际对战收益。适合希望系统学习强化学习与博弈论算法的小白或进阶学习者,也可用于毕业设计、课程设计、大作业或工程实训。包体共166个文件,以58个py源码、48个pth模型权重、18个csv评估数据、16个pkl数据文件为主,另有txt、json、md说明等辅助内容,压缩包约14MB,目录分层清晰。所有数据与脚本按实验环境分区,便于复现与二次开发。已有139人学习下载。通过完整agent实现、多算法对比框架、训练模型和评估数据,读者可以复现实验、理解DeepCFR改进思路,并可迁移到更大规模扑克环境中继续优化。
1. 德州扑克AI不是让DQN硬学:为什么遗憾最小化比奖励最大化更关键
如果照着围棋的思路把DQN直接丢到一局德州扑克上,你大概率得到的不是AI,而是一个疯狂诈唬的黑匣子。德州扑克AI的难点不在算力,而在信息不完全:你看不到对手底牌,同样的可观察状态背后对应着无数种真实牌局,价值函数天然不是一个确定函数。标题里的“算法优化”因此不指换更大网络,而是在遗憾最小化框架里改善样本效率与收敛,把CFR扩展到深层状态空间,用自博弈逼近纳什均衡。这篇文章面向有Python与深度强化学习基础、想把手上的知识真正落到一个零和博弈场景的工程师,从编码、搭建到踩坑按实操顺序展开。
2. 把一局牌变成强化学习能吃的状态:状态编码、动作抽象与奖励设计
2.1 为什么不完美信息让DQN直接失灵
很多从“python入门”走过来的朋友,第一反应是把牌面、底池、下注轮次拼成一个向量,然后丢给DQN。这个思路在完美信息游戏里成立,在德州扑克里会碰壁:DQN学的是“当前状态下的期望回报”,但扑克里一个状态并不对应一个真实世界。你看到公共牌是A-K-7,对手可能拿AA,也可能拿72o;这两个世界的正确应对完全不同。传统DQN只能在这些可能性上平均,结果就是策略变得又软又犹豫。
真正在德州扑克AI里被验证的路线是反事实遗憾最小化(CFR)家族。DeepStack、Libratus这些公开方案的核心都是先在受限动作空间里做CFR类迭代,再用深度网络压缩状态规模。深度强化学习在这里的角色不是“端到端学一个价值函数”,而是让CFR能扩展到人类无法全量遍历的牌局空间。这个定位搞清楚,后面的网络设计才不会跑偏。
2.2 用Python写状态编码:手牌、公牌与下注史怎么进网络
我一般把一局牌编码成三块:手牌、公牌、下注史。手牌和公牌都用52维one-hot;下注史按四条街分别记录双方累计下注。这里的关键坑是手牌和公牌可能指向同一张牌,写特征时一定要先去重,否则网络会以为同一张牌出现了两次。
import numpy as np SUITS = ['s', 'h', 'd', 'c'] RANKS = ['2', '3', '4', '5', '6', '7', '8', '9', 'T', 'J', 'Q', 'K', 'A'] def card_index(card: str) -> int: return RANKS.index(card[0]) * 4 + SUITS.index(card[1]) def street_bet_features(bet_history: dict) -> np.ndarray: # 每条街存 [本方总下注, 对方总下注] feats = np.zeros(8, dtype=np.float32) for i, street in enumerate(['preflop', 'flop', 'turn', 'river']): if street in bet_history: feats[i * 2] = bet_history[street][0] feats[i * 2 + 1] = bet_history[street][1] return feats def encode_info_state(hole_cards, board_cards, bet_history) -> np.ndarray: vec = np.zeros(52 + 8, dtype=np.float32) seen = set() for c in hole_cards + board_cards: idx = card_index(c) if idx in seen: continue seen.add(idx) vec[idx] = 1.0 vec[52:] = street_bet_features(bet_history) return vec这段代码的输出是一个60维向量,plant到Torch或TF里就是一行Linear(60, 256)的事。注意下注金额我建议归一化到筹码量的比例值,不要直接用绝对数,不然深码局和浅码局的特征分布差异会干扰训练。
2.3 动作离散化与合法性掩码:fold当合法动作才给奖励
无限注德扑的下注尺寸理论上连续,落地时必须离散化。我的做法是把每条街限制成固定档位,常见配置是fold / check_call / half_pot / pot / all_in五档。这里有个坑容易被新手忽略:当前无人下注时fold是不合法动作,check_call也不该拆成两个动作。所以动作空间必须配合合法性掩码使用,否则网络会学到“在免费看牌时主动弃牌”这种自杀策略。
ACTIONS = ['fold', 'check_call', 'half_pot', 'pot', 'all_in'] def build_action_mask(state) -> np.ndarray: legal = ['check_call', 'half_pot', 'pot', 'all_in'] if state.current_bet > 0: legal.insert(0, 'fold') mask = np.zeros(len(ACTIONS), dtype=np.float32) for i, act in enumerate(ACTIONS): mask[i] = 1.0 if act in legal else 0.0 return mask def masked_softmax(logits: np.ndarray, mask: np.ndarray) -> np.ndarray: logits = np.where(mask == 1.0, logits, -1e9) logits = logits - np.max(logits) exp = np.exp(logits) return exp / exp.sum()动作档位越细,信息集数量越大。我用五档起步,先把整条训练管线跑通,再回头调动作抽象。ali的尺寸选择会在第4章专门展开,因为它直接决定训练时间量级。
2.4 稀疏奖励与反事实价值:为什么不要给每一步加模拟奖励
很多做Dota/星际RL的同学习惯设计中间奖励来缓解稀疏问题。在CFR框架里,这条路最好不要走。在德扑里,中间奖励没有天然的定义:翻牌前加注可能是价值也可能是诈唬,单步收益无法反映长期策略质量。CFR的效用函数直接取牌局终点的筹码收益,每一步的“遗憾”由反事实价值自动产生,不需要人为设计。
如果你一定要走NFSP或Actor-Critic这类深度强化学习路线,我建议同样只使用终局稀疏奖励,配合经验回放和对抗采样。给“赢得底池”加1分这种伪奖励会让模型变成只看眼前利益的下注机器,胜率上不去还难调试。
3. 用Python搭一个Deep CFR训练骨架:从后悔匹配到自博弈
3.1 后悔匹配是CFR的心脏
CFR的核心不是神经网络,而是一个非常朴素的更新法则:对每个信息集、每个动作,累计“如果当初选这个动作,比实际选的动作多赢多少”,然后按正遗憾的比例生成下一轮策略。先把这个函数写对,后面接网络才有意义。
def regret_matching(regrets: np.ndarray) -> np.ndarray: positive = np.maximum(regrets, 0.0) total = positive.sum() if total < 1e-12: return np.full_like(regrets, 1.0 / len(regrets)) return positive / total参数说明:regrets是当前信息集下所有动作的遗憾值向量,正遗憾的动作才有概率被选中。分母小于阈值时返回均匀策略,这个兜底很关键,否则会出现除以零。这段代码虽然短,但它在整个训练管线里会被调用几十万次,性能上建议用纯numpy实现,别在Python循环里逐元素算。
3.2 用PyTorch定义RegretNet和AveragePolicyNet
官方Deep CFR类方案维护两个网络:一个预测“某个信息集下某个动作的遗憾值”,另一个输出“最终应该执行的平均策略”。两个网络必须分开,因为遗憾值和策略分布是两回事:遗憾值告诉你怎么修正,平均策略告诉你最终怎么打。
import torch import torch.nn as nn class RegretNet(nn.Module): def __init__(self, state_dim: int, num_actions: int): super().__init__() self.net = nn.Sequential( nn.Linear(state_dim + num_actions, 256), nn.ReLU(), nn.Linear(256, 256), nn.ReLU(), nn.Linear(256, 1), ) def forward(self, state: torch.Tensor, action_onehot: torch.Tensor) -> torch.Tensor: x = torch.cat([state, action_onehot], dim=-1) return self.net(x).squeeze(-1) class AveragePolicyNet(nn.Module): def __init__(self, state_dim: int, num_actions: int): super().__init__() self.net = nn.Sequential( nn.Linear(state_dim, 128), nn.ReLU(), nn.Linear(128, num_actions), ) def forward(self, state: torch.Tensor, mask: torch.Tensor) -> torch.Tensor: logits = self.net(state) logits = logits.masked_fill(mask == 0, -1e9) return torch.softmax(logits, dim=-1)RegretNet把动作拼成one-hot后输出一个标量,本质是在做回归;AveragePolicyNet只吃状态特征,输出经过masked softmax的动作概率。两个网络的结构刻意不一样宽,RegretNet需要更宽的容量来拟合更复杂的映射,AveragePolicyNet要窄一些,具体原因在第4章展开。
3.3 MCCFR采样:把对局遍历变成regret样本
完整CFR需要遍历整棵博弈树,德扑状态空间远超这个量级,所以用蒙特卡洛外部采样(MCCFR)替代:当前玩家全量遍历所有动作,对手动作按策略概率采样。这样每次只推进一条对手分支,训练数据量大幅下降。
def traverse(self, node, reach, me: int, regret_buffer: list): if node.is_terminal(): return node.utility(me) if node.is_chance(): return self.traverse(node.sample_chance(), reach, me, regret_buffer) cur = node.current_player() if cur == me: info_state = node.info_state_feature() legal = node.legal_actions() strategy = self.get_strategy(info_state, legal) node_value = 0.0 for action in legal: child_reach = reach.copy() child_reach[cur] *= strategy[action] action_value = self.traverse( node.child(action), child_reach, me, regret_buffer ) # 反事实到达概率:排除当前玩家的到达概率 cf_reach = np.prod( [r for p, r in enumerate(reach) if p != cur] ) sampled_regret = cf_reach * (action_value - node_value) regret_buffer.append((info_state, action, sampled_regret)) node_value += strategy[action] * action_value return node_value else: # 对手节点:采样单条分支 strategy = self.get_strategy( node.info_state_feature(), node.legal_actions() ) action = np.random.choice(node.legal_actions(), p=strategy) child_reach = reach.copy() child_reach[cur] *= strategy[action] return self.traverse(node.child(action), child_reach, me, regret_buffer)逻辑说明:当前玩家是me时,每个合法动作都做一次递归,并把该动作的sampled_regret写入buffer;对手节点则只采样一条分支,这是计算量能压下来的关键。node_value是当前策略下的期望收益,action_value - node_value度量“换成这个动作能改进多少”。这个版本是教学骨架,生产上还要补充重要性权重,否则采样偏差会随迭代累积。
3.4 最小训练循环:采样、训练网络、更新策略
把前面几块拼起来,训练的骨架就是:跑一批对局,攒regret样本;训练RegretNet;再用RegretNet的输出生成平均策略目标,训练AveragePolicyNet。每轮迭代都要重复这个过程。
for iteration in range(total_iterations): # 1. 采样阶段 regret_samples = [] policy_states = [] for _ in range(episodes_per_iter): root = new_hand() self.traverse(root, [1.0, 1.0], me=0, regret_buffer=regret_samples) policy_states.extend(self.collect_policy_states(root)) # 2. 训练 RegretNet train_regret_net(regret_samples, regret_epochs=30) # 3. 生成平均策略目标并训练 AveragePolicyNet policy_targets = [] for info_state, mask in policy_states: regrets = regret_net( torch.tensor(info_state).unsqueeze(0), torch.eye(num_actions).unsqueeze(0), ).squeeze(0).detach().numpy() target = regret_matching(regrets, mask) policy_targets.append((info_state, target)) train_average_policy(policy_targets, policy_epochs=20) # 4. 每 50 轮做一次评估 if iteration % 50 == 0: log(iteration, evaluate_exploitability(agent))参数说明:episodes_per_iter我通常设100到500,太少则regret估计方差大,太多则单轮训练耗时过长;regret_epochs和policy_epochs不需要太大,因为每轮迭代的监督信号本身就在变动,硬拟合反而会记住上一轮的噪声。
4. 算法优化先调这四个参数:迭代策略、网络容量、学习率与动作抽象
4.1 CFR+与线性加权的迭代策略
优化算法第一步不是换网络结构,而是换更新规则。经典CFR对负遗憾直接保留,CFR+则是把负遗憾直接截断为0,同时平均策略按迭代次序线性加权。这两个小改动在德扑场景里能明显加速收敛。
def cfr_plus_update(regret_sum, increment): return np.maximum(regret_sum + increment, 0.0) def linear_weight(iteration: int) -> float: return float(max(iteration, 0))逻辑说明:CFR+的本质是“过去的错误不再惩罚”,让策略更激进地朝当前最优方向走;线性加权则是让后期的策略对平均结果有更大影响,避免早期随机探索污染最终策略。实现时注意:regret_sum要在遍历过程中累加,平均策略的权重也要同步按linear_weight累加,否则CFR+的优势体现不出来。
4.2 两个网络分开配容量:别让平均策略网络太宽
我踩过最典型的一个坑,是给AveragePolicyNet用了和RegretNet一样宽的256结构。结果训练曲线看起来在收敛,实际评估时策略表现忽好忽坏。原因是平均策略网络太宽,把每轮迭代的采样噪声当成规律背了下来。
常见的做法是RegretNet用256或512,AveragePolicyNet用128到256。RegretNet要做的是细粒度回归,容量不够就欠拟合;AveragePolicyNet学的是各动作的长期平均概率,容量过高就过拟合短期波动。另一个附带好处是AveragePolicyNet参数少,在每轮生成策略目标时推理更快,训练循环整体节奏更跟得上。
4.3 非平稳目标下的学习率与批量大小
RegretNet的回归目标每轮迭代都在变,同一个信息集这轮的标签和下轮可能差很多。这种非平稳回归问题,学习率设置得过高会让网络在“追新标签”和“忘掉旧知识”之间震荡。
| 参数 | 常见范围 | 影响 |
|---|---|---|
| RegretNet学习率 | 1e-4 到 1e-3 | 过高则exploitability曲线反复跳动 |
| AveragePolicyNet学习率 | 1e-4 到 1e-3 | 过高则平均策略失去“平均”意义 |
| regret批量大小 | 512 到 2048 | 小批量在非平稳目标下梯度噪声过大 |
| 每轮训练epoch | 20 到 50 | 太多会过拟合上一轮标签 |
我一般的做法是RegretNet用Adam、学习率3e-4,批量大小1024;AveragePolicyNet也用Adam但学习率降到1e-4。如果exploitability曲线出现锯齿状震荡,先降学习率,而不是加大网络。
4.4 动作抽象粒度与信息集规模的平衡
动作抽象是整个优化里性价比最高的杠杆。动作档位越多,信息集数量呈组合级增长,但策略的理论上限也越高。这里必须做一个明确取舍,我建议按游戏规模决定。
| 动作方案 | 每条街档位数 | 适用阶段 | 训练成本 | 策略上限 |
|---|---|---|---|---|
| 三档:fold/check_call/all_in | 3 | 调通链路 | 低 | 低 |
| 五档:fold/check_call/half_pot/pot/all_in | 5 | 正式训练 | 中 | 中 |
| 七档:增加3/4_pot、overbet | 7 | 冲刺上限 | 高 | 边际递减 |
先无脑用五档跑通全流程,确认网络和采样代码没有bug后,再把half_pot替换成更细的档位。很多优化效果不明显,问题不在算法,而在动作抽象太粗,网络根本没机会表达正确策略。
5. 德州扑克DRL训练避坑:4个最常见的收敛翻车现场
5.1 Loss突然变成NaN且不再恢复
现象:训练到某个迭代轮次,RegretNet的MSE loss变成NaN,后续无论怎么调学习率都救不回来。
原因:一般有两个来源。一是AveragePolicyNet在masked softmax之前没有对非法动作做足够低的屏蔽值,导致某次前向计算出现inf减去inf;二是regret样本中出现极端大的cf_reach,乘出来的回归目标超过浮点范围。
解决:对RegretNet的输出做梯度裁剪,nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0);同时把masked softmax里的屏蔽值从-1e9改成-1e6,避免和float32的精度边界纠缠。另外建议给regret target做一次离群值截断,超过均值±5倍标准差的样本直接丢弃。
5.2 策略塌缩成只跟注不弃牌
现象:训练完成后,AveragePolicyNet在绝大多数信息集上输出fold概率接近0,面对加注永远跟注或再加注。
原因:Rregret的稀疏性。在很多信息集上,fold动作从未被真正采样到正遗憾,RegretMatching按正遗憾分布分配概率,fold天然得不到概率。AveragePolicyNet学到的supervision里fold目标全是0,自然就塌缩。
解决:在MCCFR遍历时对动作引入epsilon探索,保证fold这类低频动作用最小概率被采样到。我通常在每个决策节点做strategy = 0.95 * strategy + 0.05 * uniform。也可以对average policy的训练目标做标签平滑,给fold一个很小的本底概率,比如0.02,防止网络输出硬0。
5.3 对局胜率高但打真人就翻车
现象:自博弈评估胜率到了60%以上,换成固定基线或人手动评估时,策略变得极易被剥削。
原因:自博弈对手和自己是同一个策略,两边会共同收敛到一种非均衡的“互相对付”模式。胜率高只说明它比曾经的自己强,不能说明它接近纳什均衡。
解决:正确的评估指标是exploitability,而不是胜率。实践中我会每隔若干轮暂停训练,用当前策略对阵一个固定的、偏紧的规则策略,混入评估流程。如果对阵规则策略的EV始终偏低,说明你的自博弈路线出了偏差。这个问题在我接触的团队里出现频率极高,几乎人手一个。
5.4 训练时间爆炸:一天一夜只跑了几千手
现象:代码逻辑没问题,但单次迭代时间超长,迭代数千轮后exploitability纹丝不动。
原因:绝大多数情况是对手节点没有做采样,整棵博弈树被全量遍历;或者动作抽象里存在一个档位从未被触发,但每条路径仍在遍历。另一个常见原因是经验回放缓冲太小,导致网络反复拟合同一批样本,训练曲线看起来在动,实则在原地打转。
解决:首先检查MCCFR的对手节点是否真的只采样了一条分支,这是加速的关键。其次把RegretNet和AveragePolicyNet的训练数据池做大,我一般保留最近200到500轮的样本,保证每轮训练数据的分布变化足够平滑。训练是体力活,合理的batch和数据保留策略比调网络结构见效快得多。
6. 用exploitability和数据分布验证策略质量:先跑Kuhn扑克给代码“验光”
6.1 为什么exploitability比胜率可信
在二人零和博弈里,一个策略的exploitability指“如果对手已知你的完整策略并专门寻找最优反制策略,你能损失多少”。平均两个玩家的exploitability越小,说明策略越接近纳什均衡。德州扑克算不了全局最优反制,但在受限动作空间内可以用同样的CFR类算法估算一条best response,得到有参考价值的利用度曲线。
我一般每隔50轮训练记录一次exploitability,如果曲线稳定下降,说明策略在走向均衡;如果曲线横盘,说明训练分布有问题。看胜率曲线容易产生虚假安全感,exploitability曲线是更诚实的反馈。
6.2 先用小规模信息集验证代码正确性
完整德扑的训练周期太长,直接上手难以判断代码是否有bug。常见做法是先跑Kuhn扑克:3张牌的简化版德州扑克,状态空间极小,可以用穷举CFR求出理论均衡。把同一个Deep CFR训练骨架套到Kuhn扑克上,如果实现正确,平均策略会向理论最优策略收敛,exploitability应快速降到接近0。
python train_deep_cfr.py --game kuhn_poker --episodes_per_iter 100 \ --regret_epochs 30 --policy_epochs 20 --total_iterations 500跑通的标志不是loss低,而是exploitability降到阈值以下并维持稳定。Kuhn扑克理论均衡是已知的,如果网络策略与理论均衡的概率分布对不上,那一定是采样逻辑或网络target生成有问题。先花一天把这条链路跑稳,比直接跑完整德扑省一周的调试时间。
我的习惯是每次改动采样器或网络结构,先跑500轮Kuhn确认没有破坏收敛性,再切回五人桌或二人限制桌继续训练。这个流程看似多了一步,实际是给整个训练管线装了刹车,能拦下绝大多数的实现错误。希望帮到你。
本文还有配套的精品资源,点击获取