HUNL Poker AI — Deep CFR 训练指南
2 人 Heads-Up No-Limit Texas Hold'em, 基于 OpenSpiel + Deep CFR (External Sampling MCCFR)
对齐 Botzone 比赛规则: 初始筹码 20000, SB=50, BB=100, 筹码/100 = 得分
目录
1. 训练总览
训练分为两个阶段,必须按顺序执行:
Phase 1: Card Model (胜率预测器) Phase 2: CFR 策略网络 (MCCFR 自对弈)
┌─────────────────────────┐ ┌─────────────────────────┐
│ 生成 200万~1亿 MC 样本 │ │ 加载 Card Model (冻结) │
│ 训练 CardModel 20-100ep │ ──权重──→ │ 50000 iter 自对弈 │
│ 保存 best_card_model.pt │ │ 保存 cfr_net_checkpoint │
└─────────────────────────┘ └─────────────────────────┘
GPU 利用率: 低 (<5%) GPU: 推理 (单样本) + 训练 (大 batch)
CPU 利用率: 数据生成时高 CPU: 单核遍历博弈树 (瓶颈)
内存: 200万~0.5GB / 1亿~26GB 内存: Buffer 占主要
磁盘: 200万~500MB / 1亿~20GB 磁盘: ~1MB 模型文件
执行命令:
cd /home/e2hang/kilo/codes/poker
# Phase 1
python train_card_model.py
# Phase 2 (确认 Phase 1 完成后)
python mccfr_trainer.py
2. Phase 1: Card Model 训练
2.1 Card Model 是什么
Card Model 接收手牌 + 公共牌,输出:
- 胜率标量 (0~1): 当前牌面赢的概率
- 胜率直方图 (50 bin): 胜率的概率分布(用于 CFR 网络的 50 维输入)
它是一个小型网络,参数量 ~45K,训练很快。
2.2 训练流程
| 步骤 | 操作 | 资源消耗 (200万) | 资源消耗 (1亿) |
|---|---|---|---|
| 1. 数据生成 | 样本 × 1000 次 MC rollout | 8 核 CPU, 内存 ~2GB | 8 核 CPU 全力跑数天, 内存峰值 ~35-40GB |
| 2. 数据缓存 | 保存为 .npz 文件 |
磁盘 ~500MB | 磁盘 ~20GB |
| 3. 加载数据 | 从 .npz 读入内存 | <1s | ~2-5 分钟 |
| 4. 模型训练 | 20-100 epoch, batch=16384 | GPU <1GB, 每 epoch ~5-10s | GPU <2GB, 每 epoch ~数小时 |
| 5. 保存模型 | card_model/data/best_card_model.pt |
按验证集最小 loss 保存 | 同左 |
2.3 训练参数详解
| 参数 | 当前值 | 含义 | 调节建议 |
|---|---|---|---|
NUM_TRAIN_SAMPLES |
2,000,000 | 训练样本数 | 越多越准,但生成耗时线性增长 |
NUM_VAL_SAMPLES |
50,000 | 验证样本数 | 5 万已足够评估 |
NUM_ROLLOUTS |
1,000 | 每样本 MC 模拟次数 | 1000 是精度/速度的平衡点,不宜低于 500 |
NUM_BINS |
50 | 直方图 bin 数 | 50 是常用值,更细(100)收益不大 |
BATCH_SIZE |
4,096 | 训练 batch size | 模型极小,可以开到 16384+ |
LEARNING_RATE |
5e-4 | AdamW 学习率 | 配合 CosineAnnealing 衰减到 1e-5 |
WEIGHT_DECAY |
1e-4 | L2 正则化 | 标准值 |
NUM_EPOCHS |
100 | 训练轮数 | 100 轮足够收敛 |
LAMBDA_MSE |
0.1 | MSE loss 权重 | 总 loss = EMD + 0.1×MSE,EMD 为主 |
NUM_WORKERS |
8 | DataLoader 工作进程数 | 设为 CPU 核心数的一半左右 |
EMBEDDING_DIM |
32 | 牌面嵌入维度 | 小模型足够 |
MLP_HIDDEN |
[128, 128, 64] | MLP 隐藏层 | 小模型足够 |
2.4 网络结构
Input: hole_cards [2] + board_cards [5] (整数 ID)
│
├── Embedding(53, 32, padding_idx=52) # 53 个 token, 32 维
│ ├── hole_emb = sum(embed(hole)) # [32]
│ └── board_emb = sum(embed(board)) # [32]
│
├── concat → [64]
│
├── Backbone MLP:
│ Linear(64,128) → ReLU → LayerNorm
│ Linear(128,128) → ReLU → LayerNorm
│ Linear(128,64) → ReLU → LayerNorm
│
├── equity_head: Linear(64,32) → ReLU → Linear(32,1) → Sigmoid
└── histogram_head: Linear(64,64) → ReLU → Linear(64,50) → Softmax
参数量: ~45,000
2.5 资源估算 (128GB RAM / 96GB VRAM)
当前配置 (200 万样本)
| 资源 | 估算用量 | 说明 |
|---|---|---|
| CPU | 8 核满载 (数据生成阶段) | 多进程并行生成 MC 样本 |
| 内存 | ~4-8 GB | 数据集加载 + DataLoader 缓冲 |
| GPU 显存 | < 1 GB | 模型极小,batch=4096 也不到 1GB |
| 磁盘 | ~500 MB | train_data.npz + val_data.npz |
| GPU 利用率 | < 5% | 计算量极小,GPU 几乎在空转 |
1 亿样本配置 (NUM_TRAIN_SAMPLES=100,000,000)
每样本存储: x_hole(2×int64=16B) + x_board(5×int64=40B) + y_equity(1×float32=4B) + y_histogram(50×float32=200B) = 260 bytes/样本
| 资源 | 估算用量 | 说明 |
|---|---|---|
| CPU | 8 核满载数天 | 1亿×1000 rollout = 1000亿次 MC 模拟,这是巨大的计算量 |
| 内存 | ~26-30 GB | NumPy 数组加载: 1亿×260B ≈ 26GB,加 DataLoader 缓冲 |
| 内存 (生成期峰值) | ~35-40 GB | 8 进程并行,每进程缓存 chunk 后合并 |
| GPU 显存 | < 2 GB | 模型极小,训练显存与数据量无关,只与 batch size 相关 |
| 磁盘 (.npz) | ~15-22 GB | 1亿×260B ≈ 26GB 原始,npz 压缩率约 60-85% |
| 训练时长 | 数小时/epoch | 1亿样本 / batch=16384 = ~6104 步/epoch |
| 总 epoch 训练 | 20-30 epoch 足够 | 数据量极大,少量 epoch 即可充分学习 |
1 亿样本可行性分析 (128GB RAM / 96GB VRAM):
- 内存: 26GB 数据 + 系统/PyTorch 开销 ≈ 35-40GB,远低于 128GB ✅
- GPU 显存: 训练时 <2GB,远低于 96GB ✅
- 磁盘: ~20GB npz 文件,需确保磁盘空间充足 ✅
- CPU 生成时间: 这是真正的瓶颈。200万样本×1000 rollout 需要数小时;1亿样本是 50× 数据量,预计需要 数天 连续运行 ⚠️
- DataLoader: 1亿样本从磁盘加载到内存需 ~2-5 分钟,之后训练不受 I/O 影响 ✅
建议: 1 亿样本在硬件上完全可行,但数据生成耗时极长。建议先用 2000万-5000万 样本验证训练效果,确认收益后再决定是否扩展到 1 亿。如果 GPU 空闲,可在生成数据的同时用已有数据开始训练。
3. Phase 2: CFR 策略网络训练
3.1 MCCFR 训练流程
每个 iteration 分两个阶段:
Iteration i:
┌────────────────────────────────────────────────────────────────┐
│ 阶段 A: 数据生成 (CPU 瓶颈, 单线程) │
│ │
│ for game_idx in range(GAMES_PER_ITER): # 200 局 │
│ traversing_player = game_idx % 2 # 交替遍历 P0/P1 │
│ state = game.new_initial_state() │
│ utility = traverse(state, traversing_player, ...) │
│ │ │
│ ├── 终止节点 → 返回收益 │
│ ├── 机会节点 → 采样一个结果,递归 │
│ └── 玩家节点: │
│ ├── CardModel 推理 → 50 维直方图 (frozen, eval) │
│ ├── CFRNet 推理 → 6 维策略 (eval) │
│ ├── 对手节点: 按策略采样动作,递归 │
│ └── 遍历者节点: 对每个合法动作: │
│ clone state → 执行动作 → 递归 → 收益 │
│ 计算 regret = (动作收益 - 加权收益) / 20000 │
│ buffer.add(info_state, legal_mask, regrets, strategy) │
└────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────┐
│ 阶段 B: 网络训练 (GPU, 极快) │
│ │
│ for step in range(TRAIN_STEPS_PER_ITER): # 50 步 │
│ batch = buffer.sample(TRAIN_BATCH_SIZE) # 32768 条 │
│ card_features = batch[:, :50] │
│ env_features = batch[:, 50:] │
│ pred_regrets, pred_logits = cfr_net(card, env) │
│ regret_loss = MSE(pred_regrets, target_regrets) * mask │
│ policy_loss = MSE(softmax(masked_logits), target_strat) * mask │
│ total_loss = regret_loss + policy_loss │
│ backward + clip_grad_norm(1.0) + optimizer.step() │
└────────────────────────────────────────────────────────────────┘
3.2 CFR 网络结构
Input: card_features [50] + env_features [5] = info_state [55]
│
├── concat → [55]
│
├── Shared Backbone MLP:
│ Linear(55, 256) → ReLU
│ Linear(256, 256) → ReLU
│ Linear(256, 128) → ReLU
│
├── regret_head: Linear(128, 6) # 无激活,regret 可为负
└── policy_head: Linear(128, 6) # 后续 Softmax + legal_mask
参数量: ~114,600
Regret Matching (get_strategy):
positive_regret = ReLU(pred_regrets) * legal_mask
if sum > 0: strategy = positive_regret / sum
else: strategy = uniform(legal_actions)
3.3 训练参数详解
| 参数 | 当前值 | 含义 | 详细说明 |
|---|---|---|---|
NUM_ITERATIONS |
50,000 | 总迭代次数 | 每次迭代 = 数据生成 + 网络训练。5 万次是长期训练目标 |
GAMES_PER_ITER |
200 | 每迭代自对弈局数 | 单核跑的,设太大会让网络更新不频繁。200 是平衡点 |
BUFFER_MAX_SIZE |
10,000,000 | Buffer 声明容量 | 注意: 实际实例化用了 1,000,000,需改为使用此常量 |
MIN_BUFFER_SIZE_FOR_TRAIN |
100,000 | 最小训练数据量 | Buffer 积累到 10 万条才开始网络更新,避免早期噪声 |
TRAIN_BATCH_SIZE |
32,768 | 训练 batch size | 大 batch 让策略收敛平滑,减少震荡 |
TRAIN_STEPS_PER_ITER |
50 | 每迭代训练步数 | 50 步在大 batch 下已经过大量样本 |
LEARNING_RATE |
5e-4 | AdamW 学习率 | 大 batch 配稍低学习率,求稳 |
WEIGHT_DECAY |
1e-4 | L2 正则化 | 标准值 |
STACK_NORMALIZE |
20,000.0 | 筹码归一化因子 | 对齐 Botzone 初始筹码,regret 值域 ~[-1, +1] |
STREET_NORMALIZE |
3.0 | 街道归一化因子 | Preflop=0/3, Flop=1/3, Turn=2/3, River=3/3 |
CARD_MODEL_CHECKPOINT |
None | Card Model 权重路径 | 务必设置,否则 card_features 是随机噪声 |
3.4 每步迭代的资源消耗
阶段 A: 数据生成 (瓶颈)
| 资源 | 消耗 | 说明 |
|---|---|---|
| CPU | 1 核 100% | traverse() 单线程递归遍历博弈树 |
| GPU 推理 | 间歇性,极低 | 每个玩家节点: CardModel 1次 + CFRNet 1次 (batch=1) |
| GPU 显存 | < 1 GB | 两个模型常驻,推理 batch=1 |
| 内存增长 | ~10,000-40,000 条/迭代 | 每条 ~292 字节 (55+6+6+6 float32) |
| 每迭代耗时 | 主要取决于 CPU | 单局遍历 ~几十ms,200 局 ~几秒 |
每迭代生成数据量估算:
- 每局 HUNL 平均 ~50-200 个信息集 (取决于对局长度和动作空间)
- 200 局 × 100 信息集 = ~20,000 条新样本/迭代
- 100 次迭代后 Buffer 约 200 万条 → 开始 FIFO 淘汰 (当 max_size=1M)
阶段 B: 网络训练
| 资源 | 消耗 | 说明 |
|---|---|---|
| GPU 显存 | < 2 GB | batch=32768, 3 层 MLP, ~115K 参数 |
| GPU 计算 | 极快 | 50 步 × 115K 参数网络,<1 秒完成 |
| CPU | 空闲 | 等待 GPU |
3.5 累计资源消耗
| 指标 | 1,000 次迭代 | 10,000 次迭代 | 50,000 次迭代 |
|---|---|---|---|
| Buffer 最大占用 (1M) | ~300 MB | ~300 MB (FIFO 满) | ~300 MB (FIFO 满) |
| Buffer 最大占用 (10M) | ~3 GB | ~3 GB (FIFO 满) | ~3 GB (FIFO 满) |
| 总生成样本 | ~2 千万 | ~2 亿 | ~10 亿 |
| 模型文件 | ~1 MB | ~1 MB | ~1 MB |
| GPU 显存峰值 | < 2 GB | < 2 GB | < 2 GB |
| 训练样本 (累计) | ~16 亿 | ~163 亿 | ~819 亿 |
4. 硬件配置与参数调节
4.1 你的硬件: 128GB RAM + 96GB VRAM
核心瓶颈: CPU 单线程博弈树遍历
GPU 和内存资源严重过剩。CFR 网络只有 ~115K 参数,即使 batch=65536 也用不到 4GB 显存。Buffer 即使开到 10M 也只占 ~3GB 内存。真正限制训练速度的是 traverse() 的单线程 Python + OpenSpiel 递归。
4.2 针对你硬件的参数调节建议
Buffer 大小: 开到 10M
问题: 当前代码 buffer = CFRBuffer(max_size=1_000_000) 硬编码了 1M,但 BUFFER_MAX_SIZE = 10_000_000 常量已定义。1M Buffer 太小,在 ~50 次迭代后就满了,导致 FIFO 大量淘汰早期数据。
建议修改: 在 mccfr_trainer.py 第 475 行,将 max_size=1_000_000 改为 max_size=BUFFER_MAX_SIZE。
资源影响:
- 10M × 292 字节 ≈ 2.9 GB 内存 (你的 128GB 完全没压力)
- 好处: 保留更多早期经验,避免灾难性遗忘,策略收敛更平滑
TRAIN_BATCH_SIZE: 可以开到 65536
当前 32768 已经过大样本量了。但你的 96GB 显存完全可以承受:
| Batch Size | 显存占用 | 效果 |
|---|---|---|
| 32,768 (当前) | < 2 GB | 收敛平滑 |
| 65,536 | < 4 GB | 更平滑,更稳定 |
| 131,072 | < 8 GB | 极度平滑,但可能过度平均化 |
建议: TRAIN_BATCH_SIZE = 65536。显存占用不到 4GB,梯度估计更稳定。
注意: batch size 增大时,学习率通常需要线性缩放。65536 = 2×32768, 所以 LR 可以从 5e-4 提到 1e-3。但不建议冒进,保持 5e-4 更稳。
GAMES_PER_ITER: 保持 200 或适当提高
| 值 | 每迭代数据量 | 网络更新频率 | 建议 |
|---|---|---|---|
| 50 | ~5,000 条 | 很频繁 | 早期可考虑 |
| 200 (当前) | ~20,000 条 | 平衡 | 推荐保持 |
| 500 | ~50,000 条 | 较低 | 数据更丰富但更新慢 |
建议: 保持 200。单核遍历是瓶颈,加大只是让每次迭代更慢而不会更有效率。
Card Model 数据量: 可以大幅提升
你的 128GB 内存和 96GB 显存在 Phase 1 几乎闲置。可以把训练数据量大幅提高:
| 参数 | 当前值 | 建议值 (中等) | 建议值 (激进) | 理由 |
|---|---|---|---|---|
NUM_TRAIN_SAMPLES |
2,000,000 | 20,000,000 | 100,000,000 | 更多极端牌型覆盖 |
NUM_VAL_SAMPLES |
50,000 | 200,000 | 500,000 | 更可靠的验证指标 |
BATCH_SIZE |
4,096 | 16,384 | 16,384 | 显存完全够用 |
NUM_EPOCHS |
100 | 40 | 20-30 | 数据量更大,不需要太多 epoch |
各数据量对应的资源需求:
| 样本数 | 内存 (加载) | 磁盘 (.npz) | 生成 MC 模拟量 | 训练步数/epoch (bs=16384) |
|---|---|---|---|---|
| 200 万 | ~0.5 GB | ~500 MB | 20 亿次 | ~122 步 |
| 2000 万 | ~5 GB | ~4 GB | 200 亿次 | ~1221 步 |
| 5000 万 | ~13 GB | ~10 GB | 500 亿次 | ~3052 步 |
| 1 亿 | ~26 GB | ~20 GB | 1000 亿次 | ~6104 步 |
4.3 资源利用分析
资源使用对比 (当前参数 vs 你的硬件上限)
当前使用 硬件上限 利用率
CPU: 1 核 多核* ~12.5% (8核) / ~3% (32核)
内存: ~3 GB 128 GB ~2.3%
GPU 显存: ~2 GB 96 GB ~2.1%
磁盘 I/O: 极低 — —
* traverse() 是单线程的,无法并行化(External Sampling MCCFR 的特性)
结论: 你的硬件资源远超当前训练需求。瓶颈在 CPU 单线程遍历,而非 GPU 或内存。
4.4 推荐的生产环境参数配置
# === mccfr_trainer.py 推荐配置 (128GB RAM / 96GB VRAM) ===
NUM_ITERATIONS = 50000 # 长期训练目标
GAMES_PER_ITER = 200 # 保持不变
BUFFER_MAX_SIZE = 10_000_000 # 充分利用内存
MIN_BUFFER_SIZE_FOR_TRAIN = 100_000
TRAIN_BATCH_SIZE = 65536 # 翻倍,利用显存
TRAIN_STEPS_PER_ITER = 50 # 保持不变
LEARNING_RATE = 5e-4 # 保持不变 (batch 翻倍但求稳)
WEIGHT_DECAY = 1e-4
CARD_MODEL_CHECKPOINT = "card_model/data/best_card_model.pt" # 必须设置!
# === card_model/config.py 推荐配置 (128GB RAM / 96GB VRAM) ===
# --- 中等配置 (2000 万样本) ---
NUM_TRAIN_SAMPLES = 20_000_000 # 2000 万 (原 200 万)
NUM_VAL_SAMPLES = 200_000 # 20 万 (原 5 万)
BATCH_SIZE = 16384 # 利用显存
LEARNING_RATE = 5e-4 # 保持不变
NUM_EPOCHS = 40 # 数据多了,epoch 可以少
NUM_WORKERS = 16 # 根据 CPU 核心数调整
# --- 激进配置 (1 亿样本) ---
# NUM_TRAIN_SAMPLES = 100_000_000 # 1 亿
# NUM_VAL_SAMPLES = 500_000 # 50 万
# BATCH_SIZE = 16384
# LEARNING_RATE = 5e-4
# NUM_EPOCHS = 25 # 数据极多,25 epoch 足够
# NUM_WORKERS = 16
5. 训练效果评估
5.1 Phase 1: Card Model 评估
内置指标 (每 epoch 输出):
| 指标 | 含义 | 期望趋势 | 收敛参考值 |
|---|---|---|---|
| Train EMD | 直方图预测误差 | 持续下降 | < 0.01 |
| Val EMD | 验证集直方图误差 | 先降后平稳 | < 0.015 |
| Train MSE | 胜率标量误差 | 持续下降 | < 0.001 |
| Val MSE | 验证集胜率误差 | 先降后平稳 | < 0.002 |
| Val Total Loss | 验证集总损失 (EMD + 0.1×MSE) | 最佳模型选择依据 | < 0.02 |
判断标准:
- Val EMD < 0.015: 直方图质量可以接受
- Val EMD < 0.01: 直方图质量良好
- Val MSE < 0.001: 胜率预测精度良好
- 过拟合信号: Train EMD 继续下降但 Val EMD 开始上升 → 停止训练,使用 best model
手动测试: python card_model/test_inference.py
- 输入手牌和公共牌,查看预测的胜率和直方图
- 抽查已知牌型: AA vs random → 胜率应 ~85%, 72o vs random → ~40%
5.2 Phase 2: CFR 训练评估
内置指标 (每 iteration 输出):
| 指标 | 含义 | 期望趋势 | 说明 |
|---|---|---|---|
| Buffer | 当前 Buffer 大小 | 增长到满后稳定 | 满 1M/10M 后 FIFO 淘汰 |
| P0_avg_util | P0 平均收益 | → 0 | 零和博弈,双方接近 Nash 时趋零 |
| P1_avg_util | P1 平均收益 | → 0 | 同上 |
| Regret Loss | 遗憾预测 MSE | 下降 | 反映网络拟合 regret 的能力 |
| Policy Loss | 策略预测 MSE | 下降 | 反映网络拟合策略的能力 |
| Total Loss | Regret + Policy | 下降 | 整体训练指标 |
关键判断标准:
-
平均收益趋零 (最重要)
- 初期: |avg_util| 可能很大 (几百到几千)
- 训练中: |avg_util| 应逐渐缩小
- 收敛: |avg_util| < 100 (相对于归一化前 20000 的筹码量,<0.5%)
- 含义: 双方策略接近 Nash 均衡,无人能单方面偏离获利
-
Loss 下降
- Regret Loss: 下降 → 网络越来越能预测每个动作的遗憾值
- Policy Loss: 下降 → 网络越来越能预测最优策略分布
- 停滞信号: Loss 在 1000 次迭代后不再下降 → 可能需要调 LR 或增加数据多样性
-
策略稳定性
- 观察后期 iteration 的 avg_util 波动幅度
- 如果波动剧烈 → 策略不稳定,可能 batch 不够大或 LR 过高
- 如果平稳 → 策略收敛良好
5.3 进阶评估方法 (需额外实现)
当前代码未内置以下评估手段,但对正式比赛很重要:
| 方法 | 说明 | 实现难度 |
|---|---|---|
| 对战固定基线 | 与 Always-Call / Random 策略对战,看胜率 | 低 |
| 对战旧版本 | 新 checkpoint vs 旧 checkpoint,看胜率提升 | 低 |
| Exploitability | 计算最佳应对 (Best Response) 的可利用度 | 中 |
| NashConv | 两位玩家最佳应对价值之和 | 中 |
| 实战模拟 | 在 Botzone 模拟器中跑完整对局 | 高 |
最简评估方案 (推荐先实现):
- 训练完成后,写一个脚本让 CFR 网络与 "Always Call" 策略对战 1000 局
- CFR 网络应稳定赢 "Always Call" (胜率 > 60%)
- 随着训练推进,这个胜率应该持续提高
5.4 训练曲线解读
正常训练曲线示意:
avg_util | ──────────────
| ────/
| ────/
| ────/
0 ───|────/───────────────────────────────── iter
|
|────/────
| 翻转 (P0/P1 交替优势后趋零)
Loss |\
| \
| \___________________________ ← 收敛
| \
| \_______________
|_______________________________ iter
异常训练曲线:
Loss |\ /\
| \ / \ /\
| \____/ \____/ \___ ← 震荡不收敛
|_______________________________
→ 可能原因: LR 过高 / batch 过小 / Buffer 太小导致遗忘
6. 文件结构与数据流
6.1 项目文件
poker/
├── train_card_model.py # Phase 1 入口脚本
├── mccfr_trainer.py # Phase 2 主训练脚本
├── cfr_net.py # CFR 策略网络 (MLP + Regret Matching)
├── cfr_buffer.py # FIFO 经验回放池 (deque)
├── env_adapter.py # OpenSpiel 适配器 (动作空间 + 状态提取)
├── README.md # 本文件
├── cfr_net_checkpoint.pt # Phase 2 训练产出
├── card_model/
│ ├── config.py # Card Model 所有超参数
│ ├── model.py # CardModel 网络
│ ├── data_generator.py # MC rollout 数据生成
│ ├── dataset.py # PyTorch Dataset + .npz 缓存
│ ├── train_card_model.py # Card Model 训练循环
│ ├── test_inference.py # 交互式推理测试
│ └── data/
│ ├── best_card_model.pt # 最佳 Card Model 权重
│ ├── final_card_model.pt # 最终 Card Model 权重
│ ├── train_data.npz # 训练数据缓存
│ └── val_data.npz # 验证数据缓存
└── open_spiel/ # OpenSpiel 源码 (已编译)
6.2 数据流
┌─── Phase 1 ───┐
OpenSpiel Game ──→ 随机采样状态 ──→ (hole_cards, board_cards)
│
MC rollout × 1000
│
▼
(x_hole[2], x_board[5], y_equity, y_histogram[50])
│
.npz 缓存到磁盘
│
DataLoader → CardModel
│
Loss = EMD + 0.1×MSE
│
best_card_model.pt
┌─── Phase 2 ───┐
OpenSpiel Game (fullgame, stack=20000, SB=50, BB=100)
│
▼
traverse(state, traversing_player)
│
├── extract_cards_from_state(state) ──→ CardModel (frozen) ──→ card_features [50]
│
├── extract_env_state(state) ──→ 归一化 ──→ env_features [5]
│ [pot/20000, p0/20000, p1/20000, street/3.0, position]
│
├── info_state = cat(card[50], env[5]) ──→ [55]
│
├── CFRNet.get_strategy(card, env, legal_mask) ──→ current_strategy [6]
│
├── regret = (action_utility - node_utility) / 20000
│
└── buffer.add(info_state[55], legal_mask[6], regrets[6], strategy[6])
│
▼
CFRBuffer (deque, FIFO)
│
▼
buffer.sample(32768) ──→ CFRNet forward ──→ Loss ──→ Backprop
│
Regret Loss: MSE(pred, target) × mask
Policy Loss: MSE(softmax, target) × mask
6.3 动作空间映射
CFR 网络输出 6 个离散动作,由 BetTranslator 映射到 OpenSpiel 引擎动作:
| CFR ID | 名称 | 映射逻辑 | Botzone 边界处理 |
|---|---|---|---|
| 0 | FOLD | 直接映射到引擎 FOLD(0) | 始终合法 |
| 1 | CALL | 直接映射到引擎 CALL(1) | 始终合法 |
| 2 | MIN_RAISE | 引擎最小加注额 | 筹码不足 → Fallback 到最大合法加注 (All-in) |
| 3 | HALF_POT | 底池一半的加注 | 筹码不足 → Fallback 到最近合法加注 |
| 4 | FULL_POT | 底池大小的加注 | 筹码不足 → Fallback 到最近合法加注 |
| 5 | ALL_IN | 全部筹码 | 始终映射到引擎最大合法加注 |
Fallback 安全链: 目标加注 → _find_nearest_legal_action → Call → Fold → legal[0],绝不抛异常。
7. 常见问题
Q1: 必须先训练 Card Model 吗?
是的。 Card Model 提供 50 维胜率直方图作为 CFR 网络的输入。如果不训练,CFR 网络的 card_features 是随机噪声,训练效果极差。
设置方法: CARD_MODEL_CHECKPOINT = "card_model/data/best_card_model.pt"
Q2: 训练中途中断了怎么办?
当前代码不支持断点续训。每次运行 mccfr_trainer.py 都从头开始。如果中断了,之前的训练进度全部丢失。
建议: 定期手动保存 checkpoint (可自行在训练循环中加入 torch.save 逻辑)。
Q3: 为什么 GPU 利用率这么低?
正常现象。 MCCFR 的 traverse() 是 CPU 单线程递归遍历博弈树,只在每个节点做一次 GPU 推理 (batch=1)。GPU 在阶段 A 基本空闲,只在阶段 B 的大 batch 训练时短暂工作。
这是 External Sampling MCCFR 的固有特性,不是代码问题。
Q4: Buffer 中 BUFFER_MAX_SIZE=10_000_000 和实际 max_size=1_000_000 不一致?
这是代码中的一个不一致: 第 72 行定义了 BUFFER_MAX_SIZE = 10_000_000,但第 475 行实例化时硬编码了 max_size=1_000_000。应该改为 max_size=BUFFER_MAX_SIZE 以使用 10M Buffer。
Q5: 训练多久能收敛?
无法给出确切时间,取决于:
- CPU 单核性能 (traverse 速度)
- Card Model 质量
- 超参数设置
粗略参考:
- 1000 次迭代: 策略初步成型,可能仍有明显漏洞
- 10000 次迭代: 策略较为合理,能击败简单基线
- 50000 次迭代: 接近收敛,策略接近 Nash 均衡
Q6: 如何在 Botzone 上使用训练好的模型?
需要编写一个 Botzone 适配器:
- 加载 Card Model 和 CFR 网络的权重
- 接收 Botzone 的 JSON 输入 (手牌、公共牌、筹码、动作历史)
- 转换为 info_state → CFRNet.get_strategy() → 选择动作
- 将动作映射回 Botzone 格式输出
这部分需要额外开发,不在当前训练代码范围内。
Q7: 128GB 内存和 96GB 显存能同时跑多个训练进程吗?
理论上可以,但意义不大:
- Phase 1: 可以开 2-3 个实验 (不同超参数),每个 <8GB 内存
- Phase 2: CPU 瓶颈使得并行化困难。
traverse()单线程,多进程不会加速单个训练
如果要跑多个实验,建议串行运行 Phase 2,并行运行 Phase 1 的不同配置。
附录: 关键数值速查
模型参数量:
CardModel: ~45,000
CFRNetwork: ~114,600
信息集维度:
card_features: 50 (胜率直方图)
env_features: 5 (pot, p0_stack, p1_stack, street, position)
info_state: 55 (拼接)
CFR 动作空间:
FOLD(0), CALL(1), MIN_RAISE(2), HALF_POT(3), FULL_POT(4), ALL_IN(5)
Botzone 规则:
初始筹码: 20000, SB=50, BB=100
得分: 筹码变化 / 100
边界: 筹码不足时只能 Fold 或 All-in
归一化:
STACK_NORMALIZE = 20000.0 (筹码/底池)
STREET_NORMALIZE = 3.0 (街道)
Buffer 样本大小: ~292 字节/条
info_state: 55 × float32 = 220 bytes
legal_mask: 6 × float32 = 24 bytes
regrets: 6 × float32 = 24 bytes
strategy: 6 × float32 = 24 bytes
Card Model 每样本大小: ~260 字节
x_hole: 2 × int64 = 16 bytes
x_board: 5 × int64 = 40 bytes
y_equity: 1 × float32 = 4 bytes
y_histogram: 50 × float32 = 200 bytes
你是一个资深强化学习与分布式系统专家。我当前的德州扑克 Deep CFR 系统(基于 OpenSpiel)在训练到中期时,因为博弈树指数级爆炸,导致底层的 C++ 引擎出现 double free or corruption 内存堆损坏错误。
为了彻底解决算力和内存爆炸问题,请帮我重构代码,进行“动作空间降维”和“深度/内存双重熔断机制”。
请严格按照以下 4 个步骤修改对应文件,并输出完整的修改后代码片段:
任务 1:将 6 个动作简化为 5 个 (移除 THIRD_POT)
我们需要将 CFR 的动作空间从 6 维缩减为 5 维。 新的动作定义及索引必须严格为:
- 0: FOLD
- 1: CALL
- 2: HALF_POT (1/2 底池)
- 3: FULL_POT (1.0 底池)
- 4: ALL_IN
需要修改的文件:
env_adapter.py:- 修改
CFR_ACTIONS列表,移除THIRD_POT。 - 更新
NUM_CFR_ACTIONS = 5。 - 更新
RAISE_MULTIPLIERS字典,只保留HALF_POT和FULL_POT对应的倍率,注意索引要更新为 2 和 3。 - 检查
BetTranslator中的get_cfr_legal_mask和cfr_to_engine_action,确保逻辑与新的索引 (0到4) 严格对齐。
- 修改
cfr_net.py: 将常量NUM_ACTIONS修改为 5。cfr_buffer.py: 将__init__中的默认参数num_actions修改为 5。
任务 2:极其严格的博弈树修剪 (修改 env_adapter.py)
在 env_adapter.py 的 BetTranslator 类中,将 RAISE_CAP 从 3 修改为 1。
这表示在同一个街区(Street),只允许发生 1 次比例加注(此后只能 Call 或 All-in)。这是控制无限制德州博弈树深度的最核心参数。
任务 3:增加硬性递归深度限制 (修改 mccfr_trainer.py)
在 mccfr_trainer.py 中,为 traverse 和 _traverse_worker 函数增加一个参数 depth: int = 0。
- 在函数开头添加熔断保护:
if depth >= 40: return 0.0 # 超过极限深度,强制返回 0 收益,截断博弈树
在所有递归调用这两个函数的地方,将参数传入为 depth + 1。
任务 4:修复 C++ 内存泄漏 (修改 mccfr_trainer.py)
这是引发 double free 的直接原因。在 _traverse_worker 和 traverse 的 “3b. 遍历者回合” 循环中,由于频繁调用 state.clone() 产生了大量 C++ 对象堆积。 请在 for a in legal_indices: 的循环末尾,显式地添加一行:
del child_state
强制 Python 立即回收该节点,防止底层 Pybind11 内存分配器雪崩。
你是一个资深强化学习系统的架构优化专家。当前我的德州扑克 Deep CFR 系统已经通过限制动作空间和树深解决了内存爆炸问题。但在长时间多进程运行(如 Iteration 115)时,系统出现了偶发的 BrokenProcessPool 崩溃。
经过排查,这是由于以下三个底层原因导致的并发段错误(Segfault)或死锁:
- 主进程与 22 个 Worker 对临时权重文件
IPC_WEIGHTS_PATH发生了并发读写冲突。 - 在
_traverse_worker的对方回合(Chance/Opponent Node),直接对传入的state进行了原地修改apply_action,破坏了递归栈中的 C++ 状态。 - PyTorch 底层的 OpenMP 线程池在 spawn 模式下发生了死锁。
请帮我修改 mccfr_trainer.py 文件,严格执行以下 3 个任务:
任务 1:强制压制底层多线程死锁
在 mccfr_trainer.py 文件的最顶部(必须在 import torch 之前),写入以下环境变量,锁死所有的 C++ 线性代数库多线程:
import os
os.environ["OMP_NUM_THREADS"] = "1"
os.environ["MKL_NUM_THREADS"] = "1"
os.environ["OPENBLAS_NUM_THREADS"] = "1"
任务 2:彻底废除临时权重文件,改用参数传递
因为我们的模型参数极小(< 500KB),无需通过文件同步,直接通过函数参数传递即可做到 100% 内存安全。
修改 main() 函数的阶段 A:
删除所有向 IPC_WEIGHTS_PATH 写入和 os.replace 的代码。
直接获取当前权重:cfr_state_dict_cpu = {k: v.cpu() for k, v in cfr_net.state_dict().items()}。
在提交任务时,将字典作为参数传入:future = executor.submit(worker_traverse_batch, chunk, cfr_state_dict_cpu)。
修改 worker_traverse_batch 函数:
更新函数签名,增加参数:def worker_traverse_batch(game_indices: List[int], cfr_state_dict: dict) -> Tuple[...]:
删除原有通过 os.path.getmtime 读取 IPC_WEIGHTS_PATH 临时文件的所有逻辑(包括 try...except 块)。
直接使用传入的参数加载权重:_WORKER_STATE["cfr_net"].load_state_dict(cfr_state_dict)。
任务 3:修复对方回合的 C++ 状态污染
在 _traverse_worker 和 traverse 函数中,找到 “3a. 对方回合” 的逻辑。
原代码是直接 state.apply_action(engine_action) 并继续递归。这会破坏上一层的 state!
请修改为:
code
Python
child_state = state.clone()
child_state.apply_action(engine_action)
utility = _traverse_worker(child_state, traversing_player, card_model, cfr_net,
experiences, translator, depth + 1)
# 强制立即释放 C++ 对象,防止内存泄漏
del child_state
return utility
(请注意:如果代码中 Chance Node 发牌逻辑也是直接 state.apply_action,也请务必按照同样的逻辑修改为 clone() -> apply_action() -> 递归 -> del child_state)