ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

跑得快AI蒙特卡洛算法实战:从状态建模到UCT调优

跑得快AI蒙特卡洛算法实战:从状态建模到UCT调优 简介这是一份采用蒙特卡洛算法实现的跑得快扑克AI项目源码面向对游戏智能决策与算法应用感兴趣的开发者适合作为学习蒙特卡洛方法在实战场景落地的入门参考。该算法通过大量随机模拟来逼近复杂牌局中的最优应对能有效处理对手行为的不确定性项目则以简洁的Java代码呈现出这一过程。压缩包共9个文件其中7个Java源文件构成核心AI逻辑搭配1个README.md说明文档与1个gitignore文件包体仅9KB结构精简便于直接阅读和二次改造。已有765人浏览学习。代码按master主分支组织含src/ai等目录使用者可对照README快速理解蒙特卡洛随机采样、模拟对局与策略评估的实现思路同时可在此基础上尝试与强化学习等算法结合进一步优化决策效果也能为其他棋牌类AI的设计提供参考。1. 跑得快AI的蒙特卡洛路线为什么随机模拟能打出高水平牌局“跑得快”是一款信息不完全的牌类游戏AI 的难点不在规则复杂而在状态空间膨胀你只能看到自己的手牌对手的牌型、剩余张数、出牌习惯都是未知。规则型 AI 往往依赖人工制定的出牌策略遇到变量稍多的局面就僵硬而蒙特卡洛算法走的是另一条路——不试图精确推理对手而是通过海量随机模拟来估计每个动作的胜率。把当前局面复制成上千个虚拟牌局每个局里按概率补全未知牌、随机出牌最后统计哪张牌赢得多AI 就选哪张。这个方法在围棋、扑克等博弈中已经验证过用在跑得快上同样成立而且代码量远小于深度学习方案。这篇文章要写的是一个纯 Python 实现的 paodekuai_ai 如何组织状态、设计模拟策略、调整参数以及在实际对局中会遇到哪些坑。适合想给棋牌游戏写 AI、做离线牌局分析或者想理解蒙特卡洛在牌类项目里怎么落地的读者。我会按“状态建模 → 模拟引擎 → 性能优化 → 调参与验证”的顺序展开直接给出可运行的代码骨架。2. 跑得快AI的蒙特卡洛模拟状态表示、动作生成与胜负判定2.1 手牌与出牌记录把牌局翻译成 AI 能算的数据结构跑得快使用一副 54 张牌也可去掉大小王按各地规则AI 首要任务是维护三类数据自己的手牌、已打出的牌、剩余未知牌。核心数据结构我一般用整数列表表示牌面例如3,4,5,6,7,8,9,10,11,12,13,14,15,16,17分别对应 3 到 2、小王、大王这样排序和组合判断都比字符串快。接下来的关键问题是如何表示“出牌记录”我建议记录两个数组一个是played_cards所有已出现的牌用于推断剩余牌另一个是last_move上一家出的牌用于判断当前可压的牌型。跑得快的规则中可以出单张、对子、三张、顺子、连对、三带一、三带二、炸弹等AI 不需要一次性处理所有组合而是把“动作生成”拆成按类型枚举。# 基础牌型表示 CARD_VALUES [3,4,5,6,7,8,9,10,11,12,13,14,15,16,17] def card_count(hand): 统计手牌中每张牌的张数返回 {牌值: 张数} cnt {} for c in hand: cnt[c] cnt.get(c, 0) 1 return cnt def possible_singles(cnt): 枚举所有可出的单张 return [v for v, c in cnt.items() if c 1] def possible_pairs(cnt): 枚举所有可出的对子 return [v for v, c in cnt.items() if c 2]这段代码的作用是把手牌转换成cnt字典后续所有牌型枚举都基于这个字典。possible_singles返回所有手牌中至少有一张的牌值possible_pairs返回至少有两张的牌值。这里没有直接生成所有牌型组合而是先把基础原子动作列出来再由上层逻辑组合因为蒙特卡洛模拟中动作生成会被调用上万次越轻量越好。2.2 动作生成如何在每轮模拟中快速给出可行出牌蒙特卡洛模拟的每轮AI 需要根据当前手牌和上家的last_move决定跟牌或过牌。跑得快不允许不出除非无牌可压所以动作生成必须覆盖所有能压制上一手牌的牌型。常见做法是分两步先枚举所有单种牌型单张、对子、三张、炸弹再用回溯法组合出顺子和连对。def generate_moves(cnt, last_move): 根据当前手牌计数和上一次出牌生成所有合法动作。 last_move 为 None 表示自由出牌。 返回动作列表每个动作是牌值列表。 moves [] if last_move is None: # 自由出牌单张、对子、三张、顺子、连对、三带一、炸弹等 for v in possible_singles(cnt): moves.append([v]) for v in possible_pairs(cnt): moves.append([v, v]) # ... 这里省略三张、顺子、连对等枚举 else: # 压制模式只生成比 last_move 大的同牌型以及炸弹 last_type classify(last_move) if last_type single: for v in possible_singles(cnt): if v last_move[0]: moves.append([v]) # ... 其他牌型同理 # 同时总是加入炸弹作为特殊压制 return moves这里的关键设计是last_move为空时的自由出牌枚举和为压制时的受限枚举。在实际项目中我通常把牌型分类函数classify单独实现它负责返回single, pair, triple等类型和关键牌值供压制判断使用。注意跑得快的规则是“上家出牌后下家必须出更大的同型牌或炸弹否则只能过”所以蒙特卡洛模拟里每个动作的合法性依赖上一次动作这个依赖关系不能省略。2.3 补全未知牌与随机出牌蒙特卡洛模拟的三要素有了动作生成下一步就是模拟一局完整牌局。模拟需要三个随机化步骤补全对手手牌、随机出牌、胜负统计。补全时从“未知牌堆”中随机分配牌给两个对手这在代码里表现为一个洗牌操作。import random def simulate_round(hand, played_cards, num_opponents2): 完成一次蒙特卡洛模拟。 返回 1 表示本方赢0 表示输。 hand: 自己的手牌列表 played_cards: 已打出的所有牌列表不含自己手牌 unknown_deck [] for v in CARD_VALUES: total 4 if v not in (16, 17) else 1 # 16,17各一张 played_count played_cards.count(v) remaining total - played_count - hand.count(v) unknown_deck.extend([v] * max(0, remaining)) random.shuffle(unknown_deck) # 将未知牌分给两个对手 split len(unknown_deck) // num_opponents opp_hands [unknown_deck[i*split:(i1)*split] for i in range(num_opponents)] # 最后一个对手可能多拿几张这里简化处理 # 模拟出牌直到有玩家出完 current_hand hand[:] # ... 省略具体出牌循环 return 1 if len(current_hand) 0 else 0这个函数的核心是重新构造一个“未知牌堆”然后随机分给对手。注意这里必须排除自己手牌和已打出的牌否则模拟会出现牌数不守恒的错误。在具体出牌循环中每个玩家从自己的手牌枚举合法动作用随机选择策略选一个动作这样模拟速度很快但结果噪声较大因此需要大量模拟来平均。2.4 胜负与收益函数模拟结束后的评分标准蒙特卡洛的评分不能只记胜/负因为跑得快有“先出完”的排位第二名也算赢吗各地规则不同。我的做法是给不同名次分配收益第一名得 1.0第二名得 0.2最后一名得 0。在三家游戏中AI 的目标是最大化长期收益而不仅仅是抢第一。收益函数需要与动作评估挂钩。比如某个动作能让自己在模拟中频繁拿到第一那它就是好动作。为了让评估更细我还加入“剩余手牌数惩罚”模拟结束后如果本方没赢则收益减去剩余手牌数 / 总牌数的加权值。这个小改动可以让 AI 避免选择那些虽然没输但导致手牌堆积的动作实际效果非常明显。def evaluate_simulation(rank, remaining_cards, total_cards): 根据名次和剩余牌数计算收益 if rank 1: return 1.0 elif rank 2: return 0.2 - 0.1 * (remaining_cards / total_cards) else: return -0.2 - 0.5 * (remaining_cards / total_cards)上述收益函数中第二名如果剩余牌少收益接近 0.1而最后一名即使牌少也得到负分从而鼓励 AI 优先争第一而不是消极躲牌。3. 从朴素随机到高胜率剪枝、对手建模与 UCT 思想的引入3.1 为什么纯随机模拟不够信息不完全下的方差问题第 2 章的随机模拟是最基础的版本但它有一个致命问题随机对手的出牌方式和我们真实对手不一样。随机策略会拆掉对子、乱出顺子导致模拟结果无法反映真实胜率。比如 AI 手上有大炸弹随机模拟中对手可能永远不会出牌到需要炸弹的局面于是炸弹被低估。要解决这个偏差不能只靠增加模拟次数——模拟次数翻十倍随机噪声还占主导所以需要引入更聪明的模拟策略。我在实际项目中采用了分层模拟前几轮对手按真实概率出牌后几轮牌很少时用全随机。这样既能保持模拟速度又能让 AI 学到关键牌型的使用时机。具体实现是给模拟函数传入一个aggression参数控制对手拆牌和跟牌的概率。这个参数可以通过离线对局数据来调也可以在线自对弈学习。3.2 对手建模用剩余牌分布调整模拟权重蒙特卡洛的一个巨大优势是可以自然处理未知信息在每次模拟前我们把未知牌随机分给对手这等价于一种对手建模。但更好的做法是引入“已知信息”来修正分配概率比如对手上一轮没有压某个对子那么他手里有该对子的概率就应该降低。这种贝叶斯式的权重修正可以大幅提高模拟质量。在代码层面我给每个未知牌分配一个初始权重然后根据对手的“过牌”行为动态衰减。比如对手在last_move为对 A 时选择过牌则对 A 的权重降低其他对子权重相对升高。每次模拟抽取未知牌时按照权重进行加权采样而不是均匀洗牌。def weighted_deal(unknown_deck, weights, n_hands): 按权重将未知牌分配给多个对手 # 简化版将权重归一化后逐张采样 total_w sum(weights) prob [w / total_w for w in weights] dealt [] for _ in range(len(unknown_deck)): idx random.choices(range(len(unknown_deck)), weightsprob, k1)[0] dealt.append(unknown_deck[idx]) # 已抽出的牌权重置0 weights[idx] 0 return wieghted_split(dealt, n_hands)注意这里有个实现陷阱权重数组必须和unknown_deck一一对应每次抽样后要把对应权重置为 0否则可能重复抽同一张牌。在工程中我一般用numpy的choice加replaceFalse来做加权不放回抽样速度比random.choices快好几倍。加权建模比较适合离线训练策略参数但如果在线实时模拟权重更新成本也要考虑。3.3 UCT 与节点选择为什么跑得快不需要完整蒙特卡洛树搜索很多 3A 游戏里的蒙特卡洛会直接上 MCTS蒙特卡洛树搜索但在跑得快中完整 MCTS 的成本过高每个动作都是个节点每轮出牌后状态变化巨大保存整棵树会爆炸。我采用的折中方案是“单层 UCT”在当前手牌中对每个候选动作各跑 N 次模拟然后用 UCB1 公式选择最终动作。UCB1 公式是value c * sqrt(ln(total_sims) / visits)其中value是动作的平均收益visits是该动作被模拟的次数。但在跑得快里不同动作对应不同的“出牌权转移”这会导致收益不可直接比较。我的做法是把每个动作的收益定义为“这一手牌打完后的最终胜率”并加入爆牌惩罚系数c。def ucb_select(action_stats, total_sims, c1.4): action_stats: {动作: (平均收益, 尝试次数)} best_action None best_score float(-inf) for action, (avg_reward, visits) in action_stats.items(): if visits 0: score float(inf) else: exploration c * (math.log(total_sims) / visits) ** 0.5 score avg_reward exploration if score best_score: best_score score best_action action return best_action这里的c是探索系数值太高会让 AI 乱试平均收益差的动作太低又会让 AI 过早锁定坏动作。跑得快通常设c1.0~1.5比围棋的sqrt(2)略低因为牌局动作间收益差异较大。在实际测试中我发现把visits的初始值设为 1 比设为 0 更稳否则首次探索会被inf主导导致前几轮选动作偏随机。3.4 模拟深度控制一手牌最多模拟到牌局结束蒙特卡洛模拟如果完整跑到所有玩家出完牌耗时较长。跑得快有一个特点当前玩家的动作会影响之后若干轮的出牌但一旦自己打完模拟的剩余部分只是为了决定名次。因此我实现了“提前终止”逻辑如果模拟中出现某一方手牌数为 0立刻终止该次模拟并统计名次不再模拟剩余多余动作。另一个有效技巧是“热手牌优先”在模拟过程中如果当前玩家只剩一两张牌出牌策略可以简化为直接出所有牌因为跑得快中剩 1 张或 2 张时通常有机会报牌。这样既保证模拟完整性又大幅减少生成无效动作。4. 跑得快AI的工程化落地从原型到可对战应用4.1 代码结构把算法与游戏逻辑解耦在做 paodekuai_ai 这类项目时我习惯把代码分为三层游戏引擎层、AI 决策层、接口层。游戏引擎层负责牌型判断、法定性、玩家状态AI 决策层只接收状态返回一个动作接口层对接命令行、WebSocket 或桌面应用。这样隔离后蒙特卡洛模拟可以单独单元测试也能方便地调到 C 或 Rust 实现不影响其他部分。paodekuai_ai/ ├── engine/ │ ├── card.py # 牌型表示与比较 │ ├── game.py # 游戏流程控制 │ └── actions.py # 动作生成与合法性检查 ├── ai/ │ ├── monte_carlo.py # 蒙特卡洛核心模拟 │ ├── opponent.py # 对手建模 │ └── policy.py # 随机/加权策略 ├── interface/ │ ├── cli.py # 命令行对战 │ └── socket_api.py # 网络对战接口 └── tests/ ├── test_actions.py └── test_monte_carlo.py这个结构最大的好处是蒙特卡洛模拟器可以脱离真实游戏运行通过纯内存对象模拟多局对局。在机器上跑 10 万次模拟只需要几秒钟便于快速调参。4.2 性能优化多线程模拟与缓存牌型结果蒙特卡洛模拟天然适合并行化每次模拟之间没有数据依赖可以把 N 次模拟切成多个线程并行。Python 里用concurrent.futures很容易实现但要避免 GIL 带来的伪并行。我的做法是使用进程池转移hand和played_cards到各个 worker每个 worker 独立跑一段模拟返回统计结果。另一项显著优化是缓存“当前手牌的合法动作”。因为蒙特卡洛模拟的前几层总是从同一手牌出发动作生成只依赖hand和last_move可以建立一个lru_cache来避免重复枚举。实测中这个缓存能让动作生成耗时减少 60% 以上。from functools import lru_cache lru_cache(maxsize8192) def cached_generate_moves(hand_tuple, last_move_tuple): 手牌和上次出牌都转成 tuple 才能被缓存 cnt card_count(list(hand_tuple)) return tuple(tuple(m) for m in generate_moves(cnt, list(last_move_tuple)))使用这个函数时要注意hand_tuple必须是有序的比如sorted(hand)后再转 tuple否则同一个手牌的不同排列会产生不同缓存键。last_move_tuple同样需要规范化例如升序排列否则缓存命中率会下降。4.3 参数表值得优先调整的蒙特卡洛参数蒙特卡洛在跑得快上的效果高度依赖几个核心参数。我整理了一张表列出了参数名称、推荐范围、影响方向方便你按自己的规则来调。参数名推荐范围影响方向模拟次数num_sims500 ~ 5000越高决策越稳定延迟线性增加探索系数c1.0 ~ 1.5越小越保守越大越随机对手建模强度weight_decay0.8 ~ 0.99越高越信任历史过牌信息提前终止阈值early_stop_cards1 ~ 3剩余牌少于该值则直接找最大牌出并行进程数workers2 ~ CPU核心数调大可线性缩短模拟耗时如果你在测试中遇到 AI 频繁拆炸弹出牌就把c调小如果 AI 总是保守不出导致被对手偷跑就适当调大模拟次数。这些参数不是孤立的模拟次数提高后c可以相应调小因为探索需求降低。4.4 集成到真实游戏以命令行对战为例为了让 AI 能够被测试我写了一个简单的命令行接口让它可以跟一个随机玩家循环对战。这个接口的意义在于验证蒙特卡洛决策逻辑是否能在完整游戏循环中工作包括出牌后更新手牌、判断胜负等。python interface/cli.py --ai monte_carlo --sims 2000 --opponent random --games 100命令行参数--sims控制每步的模拟次数--opponent指定对手类型random、greedy、self--games指定总共玩多少局。在集成时我通常先跑 100 局随机对手观察 AI 是否出现非法动作或异常死循环。这里最常出现的坑是AI 在无牌可出时应该“过牌”但动作生成器返回空列表时主循环没有把“过”当成合法动作导致游戏卡死。5. 跑得快AI的进阶调优如何让 AI 更像人类并快速验证强度5.1 用“报牌”信息做二次修正跑得快有个特殊规则出牌后如果剩余牌数为 1 或 2玩家要报牌。这个信息是公开的对手听到报牌后能推断你剩余的牌型。蒙特卡洛模拟天然可以利用这一点在模拟补全对手手牌时如果对手处于报牌状态就把他的剩余牌数固定为报牌数并从牌堆中抽取对应数量的牌。if opp_announced_cards 1: # 对手只剩一张牌大概率是单张 forced_single choose_probable_unknown(unknown_deck, prefer_highTrue) opp_hand [forced_single] # 从未知牌堆中移除该牌 unknown_deck.remove(forced_single)上述代码中prefer_high表示优先给对手大牌因为人类在报单时通常留下大牌。这个简单修正能让 AI 在残局阶段更准确有效提升残局胜率。在代码库里我会把报牌信息保存在GameState中模拟时传给蒙特卡洛生成器。5.2 离线自对弈参数寻优蒙特卡洛算法的参数组合非常多手工调参效率低。我常用自对弈来寻找最优参数两个 AI 使用不同的参数组合互相对战胜率高者胜出。为了避免随机波动每对参数至少对战 200 局并用 95% 置信区间来判断显著差异。python train/selfplay.py --p1 sims1000,c1.2 --p2 sims2000,c1.0 --rounds 200这个脚本会输出双方的胜率和平均耗时。在我自己的测试中sims1000与sims3000的胜率差异很小约 52% vs 48%说明决策质量随着模拟次数增多趋于收敛反而是c的调整对胜率影响更大跑得快中合理利用爆牌机会比单纯增加计算量更有效。因此建议在调参时优先调c和对手建模强度而不是无脑提高模拟次数。5.3 验证 AI 强度时最容易忽视的指标除了胜率还要关注“平均手牌剩余量”和“平均行动延迟”。平均手牌剩余量衡量 AI 是否擅长快速出完而延迟则关系到线上应用的体验。如果牌局进行到中盘AI 某个动作的模拟需要 2 秒以上用户就会觉得卡顿。我的经验是单步模拟次数超过 3000 时必须启用并行且要设定动态模拟次数——当手牌少于 5 张时模拟次数减半因为残局状态空间小不需要太多模拟。指标合理范围观测方式胜率对随机对手 85%对贪心对手 55%100 局滚动统计平均手牌剩余低于人类平均 2 张以上每局结束统计单步延迟本地测试 500ms日志打点非法动作率0测试套件自动检查最后一招如果你想快速体验跑得快AI 的蒙特卡洛效果不需要等到整个工程写完可以直接在engine/game.py里加一个调试方法打印当前动作的所有模拟收益分布比如“出单张 3 胜率 0.31出对 3 胜率 0.27过牌胜率 0.22”这样你就能直观看到蒙特卡洛在每个局面下的判断逻辑也能迅速定位收益函数设计缺陷。本文还有配套的精品资源点击获取
返回列表