多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

期望搜索实战:用Expectimax构建带骰子随机性的爱因斯坦棋AI

期望搜索实战:用Expectimax构建带骰子随机性的爱因斯坦棋AI 简介基于期望搜索算法的爱因斯坦棋博弈软件是一款面向棋类爱好者、学生、教师及计算机博弈大赛参赛者的智能对战程序利用期望搜索评估局面并制定策略以Pygame构建简洁界面支持多种棋类规则切换。资源包共159个文件压缩后约7.38MB内含Python源码、55张PNG界面素材、sample示例数据、XML配置、TTF字体、patch补丁及Git仓库完整对象master/head/index等可还原项目开发环境与版本演进历史。目前已有122人学习下载适合课程设计、竞赛备赛及算法研究尤其适合需要理解博弈树搜索与期望值评估策略的读者。通过阅读源码和配套素材读者可以理解期望搜索算法在棋类对弈中的具体实现、实时反馈与走法建议的交互设计以及包含大量Git对象和配置文件的工程目录组织方式便于二次开发与维护。1. 爱因斯坦棋的随机性与期望搜索为什么直接套 Minimax 会翻车带骰子的棋类 AI 有个通病直接套 Minimax棋力上不去剪枝优化做得再细也白搭。爱因斯坦棋就是典型——5x5 棋盘、双方各 6 枚编号棋子、每回合掷骰决定能动哪颗子。随机性不是点缀而是规则核心。Minimax 默认对手每一步都取最优但在这里骰子 1/6 概率落在哪颗子上直接决定了你根本没有选择。期望搜索Expectimax正是为这类带机会节点的博弈设计的把骰子节点当概率加权平均行动节点才做 max/min。这篇笔记写给想把爱因斯坦棋做成可对战软件的开发者也写给被随机棋类胜率卡住、想从搜索结构上找突破的人。读完你能自己实现一个可跑的期望搜索博弈软件并知道参数怎么调、坑在哪里。2. 把爱因斯坦棋规则写进代码棋盘、走法生成与骰子建模2.1 规则版本先定死骰子、编号与不可动分支爱因斯坦棋不同平台规则有细微差异动手前必须把版本钉死。我采用的是一套较为通行的规则5x5 棋盘双方各 6 枚棋子编号 1 到 6初始摆在靠近己方的两行每回合掷一枚六面骰掷出几就必须移动编号几的己方棋子每颗子只能朝对手方向直走或斜走一格不能后退目标格是空位或对方棋子时可以走是己方棋子则非法若掷出编号的棋子已被吃掉或完全不能动则该回合空过。先走到对方底线或吃光对方全部棋子的一方获胜。这套规则里最大的非对称点在第 6 条——空过。它不是一个错误处理而是概率空间里真实存在的一个分支。期望搜索里如果把不可动分支漏掉六个骰子面的概率加起来不是 1评估值会出现系统性偏差。后面第 5 章我会专门展开这条踩坑记录这里先记住结论骰子建模必须把无合法走法当成一个合法分支处理概率同样是 1/6。2.2 棋盘与棋子的最小实现用坐标而不是向量我一般用 Python 做原型坐标采用 (row, col)row0 为黑方底线row4 为白方底线。棋子状态用字典存编号映射到坐标这样骰子点数可以 O(1) 查出对应棋子位置。棋盘用 5x5 二维数组元素为棋子归属方和编号空位为 None。SIZE 5 # 黑方在上方(row0一侧)白方在下方(row4一侧) # pieces 结构: {black: {1: (r,c), 2: (r,c), ...}, white: {...}} class Board: def __init__(self): self.grid [[None] * SIZE for _ in range(SIZE)] self.pieces {} self.pieces[black] { 1: (0, 0), 2: (0, 1), 3: (0, 2), 4: (0, 3), 5: (0, 4), 6: (1, 2) } self.pieces[white] { 1: (4, 0), 2: (4, 1), 3: (4, 2), 4: (4, 3), 5: (4, 4), 6: (3, 2) } for side, dic in self.pieces.items(): for pid, (r, c) in dic.items(): self.grid[r][c] (side, pid)这段代码把棋盘布局和编号到坐标的映射分开存是为了后面走法生成时不被棋盘遍历拖慢。5x5 棋盘只有 25 个格子暴力遍历也不是不行但搜索树每层要调几千次走法生成能省则省。用字典存棋子位置骰子点数一出来直接定位到棋子不需要全盘扫描。2.3 走法生成的关键方向向量与己方碰撞移动规则是只朝对方方向走一步可直可斜。黑方向下row1白方朝上row-1方向向量正好互为相反数。斜向有左斜和右斜两个选择加上直行每颗子最多三个候选目标格。def _step_candidates(self, r, c, side): dr 1 if side black else -1 # 黑向下白向上 for dc in (-1, 0, 1): nr, nc r dr, c dc if 0 nr SIZE and 0 nc SIZE: yield nr, nc def legal_moves(self, side, die): if die not in self.pieces[side]: return [] # 该编号棋子已被吃空过分支 r, c self.pieces[side][die] moves [] for nr, nc in self._step_candidates(r, c, side): target self.grid[nr][nc] if target is None: moves.append(((r, c), (nr, nc))) # 走空位 elif target[0] ! side: moves.append(((r, c), (nr, nc))) # 吃对方棋子 # 目标格是己方棋子时合法走法为空直接跳过 return moveslegal_moves返回空列表时调用方必须把它理解为该点数空过而不是出错了。这是整个期望搜索概率模型的地基。另外注意吃子走法和走空位在走法生成层面没有区别都是一个坐标移动真正的差异体现在评估函数里——吃掉对方编号越大的棋子对后续骰子分支的影响越大因为对方少了一个可动编号空过概率会上升。这个联动效应是爱因斯坦棋评估函数里最值得挖掘的特征之一。def dice_branches(self, side): result [] for die in range(1, 7): moves self.legal_moves(side, die) result.append((die, moves)) return resultdice_branches一次性生成当前回合六个骰子面的完整分支。注意它没有过滤掉空 moves 的分支——前面强调过空过也是概率空间的一部分。这个函数的返回值会被期望搜索直接消费所以它的正确性直接影响整个 AI 的棋力。写完之后建议先用一个手工摆好的局面验证某个编号的棋子被吃时对应分支 moves 是否为空某个棋子被己方棋子堵死时是否产生了能走却被堵的误判。3. 用期望搜索构建 AI机会节点、对手节点与搜索深度3.1 从 Minimax 到 Expectimax随机节点为什么不做 max/minMinimax 的基本假设是每一步都由某个玩家做出确定性最优选择。轮到己方取 max轮到对手取 min。爱因斯坦棋的问题在于每个回合开始前还有一个掷骰子环节它不是由任何玩家控制的而是概率事件。把骰子当 max 分支处理等于假设骰子每次都会给你想要的点数当 min 分支处理等于假设骰子每次都给你最差的点数。两种都是错的正确做法是把骰子节点的值定义为六个骰子面值的期望。期望搜索的节点类型因此有三种己方行动节点取 max、对手行动节点取 min、骰子节点取概率加权平均。这里的关键在于骰子节点后面跟着的还是行动节点。掷出点数 3 之后该点数可能有多颗子能走实际只有一颗因为按编号移动也可能一颗都不能走。能走时是当前行动方从合法走法里选最优——这是 max 或 min 节点不能走时直接过渡到对方回合不产生行动选择。所以搜索树的形态是骰子节点 - 行动节点 - 骰子节点交替出现。3.2 期望搜索的代码实现骰子分支如何算平均值下面这段是核心搜索函数。我采用每个骰子分支消耗一层深度的写法即 depth 表示掷骰并行动的回合数这样深度语义更接近棋手理解的看几步棋。def expectimax(board, depth, ai_side, current_side): winner board.winner() if winner is not None: return 10000.0 if winner ai_side else -10000.0 if depth 0: return evaluate(board, ai_side) expectation 0.0 for die in range(1, 7): prob 1.0 / 6.0 moves board.legal_moves(current_side, die) if not moves: # 空过分支不产生走法选择直接换人 v expectimax(board, depth - 1, ai_side, opponent(current_side)) expectation prob * v continue values [] for move in moves: board.apply(move) values.append(expectimax(board, depth - 1, ai_side, opponent(current_side))) board.undo(move) if current_side ai_side: branch_value max(values) # 己方掷出此点取最优走法 else: branch_value min(values) # 对方掷出此点假设对方取最优 expectation prob * branch_value return expectation逻辑分成四步。第一步遍历六个骰子面每个面概率固定为 1/6不需要考虑骰子是否公平之外的因素。第二步判断该点数是否有合法走法没有则空过递归进入对方回合有则对每个合法走法分别递归。第三步把同一骰子面下的多个走法结果合并当前行动方是 AI 自己时取 max是对手时取 min。第四步六个骰子面的分支值按概率加权求和得到当前节点的期望值。参数上最常见的疑问是depth 减 1 放在哪里。我建议放在每次递归调用里而不是放在机会节点整体。原因很简单空过分支和行动分支消耗的回合数是相同的都是一次完整的掷骰行动轮。如果只在行动后减深度、空过不减AI 会被迫偏好能动的局面因为能动才能让搜索继续向下延伸这个偏好是评估函数里没有的隐性偏差。3.3 深度、分支因子与迭代加深实盘响应时间怎么控制爱因斯坦棋的分支因子比围棋小得多每个骰子面最多一个合法走法因为编号唯一所以每个行动节点最多 3 个候选走法直行、左斜、右斜其中还有可能被己方棋子或边界过滤。理论上每层搜索最多展开 6 个骰子面乘以 3 个走法约 18 个节点但实际大部分骰子面只有 1 到 2 个走法很多面直接空过。深度 4 的搜索规模通常在一万到十万节点之间纯 Python 单线程也扛得住。不过决策耗时不能只看平均节点数。如果某个局面中多颗棋子都接近对方底线每个骰子面都有 2 到 3 个候选分支因子会明显膨胀。我一般不用固定深度而是用迭代加深加时间预算def choose_move(board, ai_side, time_budget_ms100): best_move None for depth in range(2, 8): start time.time() move, value search_root(board, depth, ai_side) elapsed (time.time() - start) * 1000 if elapsed time_budget_ms: break best_move move if abs(value) 9000: # 已发现必胜走法不再加深 break return best_movesearch_root是最上层单独写的函数遍历当前骰子面的所有可能对每个可能选出最优走法返回 (走法, 评估值)。迭代加深的收益是随时可以中断即使上一次深度没有算完手里也有上一轮完整的结果。实际对局中我会把时间预算设为 100 毫秒到 200 毫秒深度通常落在 4 到 5。超过 5 层以后收益明显变小因为骰子随机性的方差会让深层评估值的置信度下降这属于期望搜索本身的边界。4. 评估函数与搜索参数让 AI 看得再深也不怕骰子4.1 评估函数拆清楚别把赢棋条件混进特征分评估函数是期望搜索里最容易偷懒也最容易出错的部分。常见的反例是把吃子权重设得很高AI 就会满盘追杀对方大子结果对方一颗小卒子跑到了底线直接输了。这说明评估函数里终局条件和局面特征必须严格分离。终局条件永远在搜索函数开头单独判断返回大分差评估函数只处理还没结束的局面。我采用的评估特征有三类。第一是材料差双方存活棋子数量差值权重 10。第二是前进距离每颗棋子离对方底线的行数差权重 1.2。第三是机动性当前局面下六个骰子面中可动面数量权重 0.5。最后这条很多人忽略——可动面越多说明骰子掷出空过的概率越低间接代表局面掌控力。def evaluate(board, ai_side): opp_side opponent(ai_side) material (len(board.pieces[ai_side]) - len(board.pieces[opp_side])) progress 0.0 for side in (ai_side, opp_side): direction 1 if side black else -1 base 0 if side black else 4 for pid, (r, c) in board.pieces[side].items(): d abs(r - base) # 已走距离越大越接近对方底线 sign 1 if side ai_side else -1 progress sign * d mobility count_movable_faces(board, ai_side) return 10.0 * material 1.2 * progress 0.5 * mobility注意 mobility 只统计 AI 自己的可动面数不统计对方的。原因是对手可动面数已经通过对局过程隐含在棋子位置里了再单独加一项会让它被重复计算。count_movable_faces的实现就是对六个骰子面依次调legal_moves并计数五个格子的小棋盘开销很低可以放心调用。这个评估函数的三个权重初始为 10 / 1.2 / 0.5我建议先用默认值跑一批自对弈再按胜率微调不要拍脑袋频繁改动。4.2 搜索参数怎么设从节点预算到概率裁剪的边界搜索参数比评估权重更影响实际体验。我常用的一组参数是时间预算 150 毫秒、迭代加深初始深度 2、最大深度 6、终局判定分数 10000。这组参数在普通笔记本上单步耗时稳定在 20 到 100 毫秒之间深度通常能走到 4 到 5。参数默认值作用调参方向时间预算150 ms控制单步响应对战平台要求实时则降到 50初始深度2保证快速出第一步开局可提高到 3最大深度6防止极端分支耗时爆炸棋力优先可到 7终局分数10000必须大于任意评估差值不要调到 5000 以下空过分支概率1/6概率模型基线不同规则版本需确认骰子面数关于概率裁剪要泼一盆冷水期望搜索里对骰子分支做裁剪的收益远小于 Minimax 里的 alpha-beta 剪枝。因为每个骰子面概率相同、分支又少剪掉任何一个都会直接破坏概率归一化。真正值得做的是对行动节点的 alpha-beta 剪枝——在 max/min 节点上可以安全剪枝因为候选走法之间仍是确定性选择关系。不过 5x5 棋盘分支太窄剪枝收益有限我一般只在深度超过 5 时才开启。4.3 转置表节省重复搜索但别让旧骰子污染新局面转置表在期望搜索中的作用比 Minimax 里更微妙。爱因斯坦棋没有玩家轮流移动的固定回合结构——骰子点数决定行动方而空过会直接跳到对手回合。同一个棋盘布局可能对应黑方刚掷完骰子待行动和白方刚掷完骰子待行动两种不同状态。如果转置表的 key 只存棋盘格子布局这两类状态会被混在一起评估结果就废了。正确的 key 必须包含四个要素棋盘布局快照、当前行动方、骰子点数如果是在行动节点缓存、或者当前阶段骰子节点/行动节点。代价是转置表的命中率下降但正确性优先。我通常在深度大于等于 4 时才启用转置表浅层搜索的重复局面不多不值得花序列化和哈希的开销。5. 期望搜索落地避坑从错算概率到平局误判的 5 个教训5.1 骰子分支漏算不可动导致期望虚高现象AI 早期表现激进经常主动送吃大子胜率却上不去。排查发现搜索树里六个骰子面的概率加起来不等于 1。原因实现骰子分支时直接过滤掉了legal_moves返回空列表的情况只对可动分支做递归。空过分支被丢弃后期望值等于骰子总能动的加权平均AI 高估了自己后续回合的行动能力于是敢走冒险棋。解决dice_branches里保留空 moves 分支并在expectimax中显式处理无走法则递归换人。最好写一个 debug 函数打印每个骰子面的概率和 value人工核对总和是否为 1。5.2 评估函数把吃子权重抬高导致 AI 送对方到家门口现象AI 见到能吃的子就追哪怕对方另一颗子已经冲到底线前一步也视而不见最终被一子偷家。原因材料差权重大于前进距离权重且吃子带来的短期增益明显搜索在有限深度内会优先选择立刻吃子的分支。这里的核心问题是赢棋条件先到对方底线没有独立于评估函数被材料差稀释了。解决终局必须在搜索入口单独判断并返回大分差评估函数中材料权重和前进距离权重的比值建议控制在 10 : 1.2 左右并定期用终局局面回归测试。我在测试中专门构造了一组对面即将到底、我方有吃子机会的局面确保 AI 每次都选择回防或冲刺而不是贪吃。5.3 固定深度导致黑匣子式棋力波动现象同一套参数下AI 有时强得离谱有时连一步送死棋都看不出来。原因深度固定时某些局面恰好能算到关键节点的深处另一些局面因为分支稠密只能算到较浅位置有效搜索深度不一致。更隐蔽的是——深度为偶数时 AI 倾向于保守等待奇数时倾向激进进攻这个波动跟实际局面无关完全是奇偶性带来的系统性偏差。解决放弃固定深度改用迭代加深加时间预算。时间预算保证每步思考量稳定不会再出现这步秒出、那步想半天的体验差。如果必须固定深度至少把深度设为奇数因为爱因斯坦棋的胜负步数通常较短奇数深度能多覆盖一次自己的行动。5.4 转置表缓存了旧骰子系统导致重复局面误判现象AI 在接近终局的局面里反复走同一个循环动作既不出击也不防守像卡住了一样。原因转置表 key 只包含棋盘格子布局不包含阶段信息。同一个布局可能来自不同骰子面和不同行动方缓存的值被错误复用到另一个阶段导致搜索认为该局面已经评估过并返回了历史值。解决key 里必须带上当前行动方和阶段标记骰子阶段还是行动阶段再加上步数计数器用来排除重复局面导致的平局误判。我在转置表结构里加了一个version字段每次搜索开始时递增缓存项只在本轮搜索内生效不跨回合复用。5.5 每步重搜但搜索树根没刷新棋力忽高忽低现象AI 前几步计算认真中间某步突然选择了明显很差的走法之后又恢复正常。原因部分实现为了性能在每步之间复用上一回合的搜索树或缓存结果。但爱因斯坦棋每步掷骰后局面已经变了复用的树根是旧骰子点的旧走法集等于在错误的状态上做决策。解决每次掷骰后强制清空本步搜索缓存从新根节点开始。这里的代价很小——5x5 棋盘深度 4 的搜索也就几万节点重新算比复用一个错误缓存更省调试时间。我在选择走法前先打印一遍待选走法集合确认集合与当前骰子点号一致再进搜索。6. 进阶验证用自对弈和骰子模拟器校准期望搜索 AI6.1 批量自对弈先过一个基础胜率门槛写一个自对弈脚本让期望搜索 AI 对战一个贪吃加前进的基线策略 AI。基线策略规则很简单能吃子就吃没得吃就朝底线走若多点可选则随机。这个基线棋力不高但能暴露期望搜索 AI 的基础缺陷——比如明明能看清两步杀却走错那就是搜索或评估实现有问题。def battle(agent_a, agent_b, rounds200, max_steps120): win_a win_b draw 0 total_steps [] for i in range(rounds): board Board() black_agent agent_a if i % 2 0 else agent_b white_agent agent_b if i % 2 0 else agent_a for step in range(max_steps): winner board.winner() if winner: break side black if step % 2 0 else white agent black_agent if side black else white_agent move agent(board, side) if move: board.apply(move) w board.winner() if w black: win_a 1 if black_agent is agent_a else 0 win_b 1 if black_agent is agent_b else 0 else: draw 1 total_steps.append(step) return win_a, win_b, draw, sum(total_steps) / len(total_steps)这个脚本里我先手轮换放在对局编号的奇偶上避免先手优势干扰胜率统计。max_steps120是为了兜底——理论上每回合都有可能空过不能无限等下去。运行 200 局后期望搜索 AI 对基线策略的胜率一般应该超过 70%平均步数在 40 到 60 之间。如果胜率低于这个水平先回去检查骰子分支概率和评估函数的终局判断。6.2 骰子模拟器验证机会节点的概率假设期望搜索里最核心的假设是每个骰子面概率相等且独立。真实对局里骰子点数对应的棋子可能已经被吃导致该面空过也可能棋子被己方棋子堵死同样空过。我用一个模拟器统计每个点数实际可动的频率与搜索里的 1/6 加权假设做对比def dice_distribution(board, side, trials10000): movable_counts [0] * 6 for _ in range(trials): die random.randint(1, 6) if board.legal_moves(side, die): movable_counts[die - 1] 1 return [c / trials for c in movable_counts]如果某个点数长期不可动比如编号 6 的棋子已被吃模拟器会显示该点可动频率趋近 0。把这份统计和搜索树里该分支的出现频率对比能快速发现概率模型假设和实际规则实现之间的偏差。这个验证在规则有改动时尤其重要——比如有人把骰子改成 1 到 6 但棋子只有 5 颗必须重新推导空过概率。6.3 决策日志把每步的评估值留下来复盘AI 棋力问题最难受的一点是看不出哪步错。我习惯在每次choose_move后写一行 JSONL 日志包含当前局面、骰子点数、候选走法、每个走法的评估值、最终选择。复盘时看到候选里有直接冲到底线的一步评估值却低于一步吃子立刻就明白评估函数偏了不需要反复黑箱试参数。log_entry { step: step_count, side: side, die: die, candidates: [str(m) for m in moves], values: [round(v, 2) for v in branch_values], chosen: str(move) }评估值values和候选走法一一对应这是最有用的调试素材。对比多局日志后我总结出一个习惯先校准终局判断——确保所有必赢局面返回 10000 而不是 9999再校准前进距离——让 AI 在有吃子和有冲刺两种选择时偏向后者最后才动材料权重。这个顺序踩过太多次希望帮到你。本文还有配套的精品资源点击获取
返回列表