Python实现幻影围棋AI:不完全信息博弈与蒙特卡洛树搜索实战
简介Ghost-master是基于Minigo的幻影围棋项目面向围棋AI爱好者与Python开发者完整实现人机对弈与AI自对弈功能。项目融合卷积神经网络与蒙特卡洛树搜索通过自我对局持续优化落子策略无需依赖大规模训练数据即可高效学习适合从基础到进阶的AI围棋研究者。压缩包共18个文件以14个Python脚本为主体覆盖对弈主程序、神经网络模型定义、棋局特征提取、蒙特卡洛搜索逻辑及人机交互界面并附带预训练模型权重、索引数据与说明文档脚本按功能模块划分整体大小仅1.47MB目录结构清晰便于快速部署和二次开发。已有926人学习下载通过源码可直接启动人机对战或让两个AI自动互博观察棋风变化还能从特征工程、网络训练到落子选择完整理解深度学习围棋程序的实现脉络适合作为课程设计或AI实验的参考项目。1. 幻影围棋是什么一个Python棋类AI的“半盲棋”难题我把 Ghost-master_ghost_python_幻影围棋_ 这个项目读完后的第一反应是它把围棋AI里最常见、也最容易被忽略的“信息不对称”问题挑了出来。传统围棋双方都在同一张棋盘上落子而幻影围棋的规则是——每个玩家只看到自己的落子对方的棋子在哪儿只能靠“禁止落子”的反馈和记忆去猜。这直接改变了博弈树的形态搜索到的局面不是一个完整棋盘而是“己方视角 对敌方落子的估计分布”。对这个方向感兴趣的读者简单说就三类想学博弈搜索但嫌围棋AI太重的人做游戏AI但手头只有单机算力的独立开发者以及想拿一个“不完全信息博弈”项目练手、把Python写得更扎实的从业者。Ghost-master 这一套Python实现的价值不在棋力多高而在把幻影围棋的规则、搜索和评估拆成了你能直接改、能跑、能复现的代码。2. 先跑通自对弈幻影围棋的最小可行命令2.1 环境准备Python版本与依赖的边界先说环境。这个项目主体是Python但我测下来对版本不算挑剔3.8到3.11都能跑。最稳的组合是Python 3.9以上 numpy因为棋盘状态用二维数组表示时numpy的切片和广播操作会让动作生成代码短一半。安装依赖时别急着pip install一堆东西幻影围棋的核心逻辑没有第三方库也能跑numpy只是加速用。# 建议在虚拟环境里操作避免把系统Python搞乱 python -m venv ghost-env source ghost-env/bin/activate # Windows下用 ghost-env\Scripts\activate pip install numpy这里的逻辑很直白虚拟环境隔离项目依赖避免和系统里其他Python项目互相污染。numpy只要装最新稳定版即可项目里用到的接口——np.zeros、np.where、数组拷贝——都是十年没变过的稳定API不存在新版本把老代码搞挂的问题。2.2 跑一局自对弈入口脚本与棋盘状态打印项目里最常见的启动方式是跑一个自对弈脚本让两个AI互相下完一整局然后把落子序列和胜负结果打印出来。我一般会先跑默认参数确认整条链路通不通再改参数看棋力变化。python play.py --player1 random --player2 random --games 1 --size 9 --print-board这条命令的逻辑--player1和--player2各自指定选手策略random表示随机落子--games控制对局数先跑一局--size设棋盘尺寸幻影围棋常用9路因为19路的信息隐藏会让随机对局变得更不可读--print-board是让每一步都打印当前棋盘方便肉眼确认规则实现是否正确。跑完你会看到类似Player1 wins by 5.5的输出这是贴目后的结果。参数说明这里random是最低置信度的策略目的是验证规则引擎没有崩溃。如果你一上来就跑带搜索的策略出了问题很难分清是规则引擎的bug还是搜索算法的bug。2.3 棋盘状态的数据结构为什么用两个矩阵而不是一个幻影围棋实现里第一个容易看不懂的地方是棋盘状态。传统围棋用一个[size, size]矩阵就够了幻影围棋至少要两个一个代表“我方视角下哪些位置有我的棋子”另一个代表“哪些位置已经被对方占过或禁入”。class PhantomBoard: def __init__(self, size9): self.size size self.own np.zeros((size, size), dtypenp.int8) self.forbidden np.zeros((size, size), dtypenp.int8) def apply_move(self, move, is_self): x, y move if is_self: self.own[x][y] 1 else: self.forbidden[x][y] 1这段代码里最关键的是apply_move的拆分自己的落子写进own对方的落子不直接写棋盘而是标记为forbidden。这个设计的核心原因是幻影围棋规则里你只有在尝试落子到对方棋子位置时才会收到“不能下”的反馈。所以forbidden矩阵实际是“通过失败反馈逐步逼近对方棋形”的累积信息。很多新手在这里翻车——把对方的落子也写进自己的可见棋盘导致搜索阶段用上了不该知道的信息棋力虚高但规则上不合法。3. 从随机到有脑动作生成与合法落子判定3.1 为什么幻影围棋的合法动作比围棋多一层约束传统围棋的合法落子只有两个约束不能落在已有棋子的位置不能落在导致自己无气的点。幻影围棋多了一个你不能落在forbidden矩阵里标记的位置但问题是你不知道forbidden是否完整。换句话说合法动作的集合是动态收窄的——随着对局推进你收到更多“不能下”的反馈forbidden越来越大可探索的位置越来越少。class SimplePolicy: def __init__(self, board): self.board board def legal_moves(self): size self.board.size own self.board.own forbidden self.board.forbidden occupied (own forbidden) 0 return [(x, y) for x in range(size) for y in range(size) if not occupied[x][y]]这段代码把own和forbidden叠加就得到了当前视角下的“不可落子集合”。注意这里没做气liberties的检查原因是幻影围棋的提子规则在隐藏信息下更复杂——对方棋子的气你根本看不到所以标准实现里往往简化掉气的计算只保留“不能下在已占位”这一条。这个简化是项目能跑起来的关键但也意味着棋力天花板不高后面避坑章节会展开。3.2 贴目与终局幻影围棋怎么算胜负传统围棋终局是双方确认边界后数空幻影围棋的终局判定在妥协方案里通常走“双方都无合法动作可下”的路线。这不是规则原文而是工程实现时的常见取舍没有完整棋盘信息没法可靠地数空只能用“无法继续落子”作为终止条件。# 查看终局处理逻辑的常见做法 grep -n is_game_over src/engine.py | head -20is_game_over的判定逻辑通常是当own和forbidden覆盖了所有交叉点也就是棋盘上没有空白位置可以再下就直接进入结算。结算时按“己方棋子数 贴目”来算不再数空。这种简化最直接的影响是对局会偏战斗、偏围地因为空白点一旦被填满就结束了所以双方都会倾向于尽快占满可见的空白。如果你想让棋力更强可以考虑把is_game_over改成“连续若干回合无人落子再终局”效果接近传统围棋的单官收完。4. 给AI装上搜索蒙特卡洛树搜索的适配与参数调优4.1 隐藏信息下的MCTS信念状态与随机模拟的取舍幻影围棋的搜索和普通围棋最本质的区别在于你在搜索树里展开的每一个节点都不是一个确定局面而是一个“信念状态”——你对自己棋子位置的确定认知 对对方棋子位置的估计分布。最常见的从业方案不是去精确推信念分布而是用蒙特卡洛模拟来近似从当前己方视角开始每次随机猜测对方棋子的可能位置然后按完整棋盘的规则去走。def mcts_search(board, simulations200): root PhantomNode(board) for _ in range(simulations): node root # 选择阶段按UCB公式找最值得探索的子节点 while node.children and not node.is_terminal(): node node.best_child() # 扩展阶段展开一个合法动作 if not node.is_terminal(): node.expand() # 模拟阶段随机走到底 winner node.simulate() # 回传阶段把结果反向传播到根节点 node.backpropagate(winner) return root.best_move()这段代码里没有计算精确信念分布而是用simulate()去抽样。这是工程上最常见的做法与其花算力维护一个巨大的隐藏信息模型不如让模拟过程去对冲不确定性。simulations200是低于常规的参数实际跑9路幻影围棋时我建议至少给到500因为隐藏信息导致模拟方差比完整信息围棋大得多样本量不够时选出的落子几乎和随机差不多。4.2 UCB公式的三个必调参数MCTS 的核心在best_child()里的UCB公式。这个公式有一个常数c控制“探索”和“利用”的平衡在幻影围棋里这个常数必须往大了调。def ucb_score(self, node, c1.4): exploitation node.wins / (node.visits 1e-9) exploration c * math.sqrt(math.log(self.visits 1) / (node.visits 1e-9)) return exploitation exploration参数说明c1.4是传统围棋常被推荐的起点但在幻影围棋里由于信息隐藏导致胜率估计噪声大我会把c提到2.02.5。你观察到的现象应该是c太小比如0.5时AI 会反复下同一个位置不去探索其他可能c太大比如5.0时AI 落子接近随机。另外visits初始值那种1e-9的防零处理不能省否则首次访问的子节点会直接除零。4.3 模拟策略随机还是加权随机搜索的效果一半看选择策略一半看模拟playout策略。完整信息围棋里可以用快速走子策略3×3 区域内优先打吃、逃跑幻影围棋里由于看不见对方的棋快速走子策略大部分失效。我用下来最不坏的选择是加权随机——空点上越靠近己方已有棋子的位置权重越高。def weighted_playout(board, playout_moves30): for _ in range(playout_moves): legal board.legal_moves() if not legal: break # 计算每个合法点的权重离己方最近棋子越近权重越高 weights [] for move in legal: dist min(manhattan_distance(move, own_stone) for own_stone in board.own_stones()) weights.append(1.0 / (1.0 dist)) move random.choices(legal, weightsweights)[0] board.play(move) return board.winner()这段代码的计算逻辑每个合法落子点的权重取“到最近己方棋子的曼哈顿距离的反比”也就是离自己棋子近的位置更可能被模拟下到。这个策略的思想是——在看不见对方棋子的情况下抱团扩张比天女散花更容易形成确定的地。实际测试下来加权随机比纯随机的模拟在对局胜率上有大约58个百分点的提升在9路棋盘上够用了。5. 幻影围棋AI踩坑实录现象、原因与解决办法5.1 自对弈到中盘直接报错slice indices must be integers现象跑大概几十步后程序抛TypeError: slice indices must be integers or None or have an __index__ method。原因棋盘矩阵用的是np.zeros但代码里某个地方把坐标写成了浮点数最常见来源是MCTS的节点访问次数做了除法后直接当成坐标用。解决在apply_move入口处做一次int()强制转换并把坐标的合法性校验放在最前面。def apply_move(self, move, is_self): x, y int(move[0]), int(move[1]) if not (0 x self.size and 0 y self.size): raise ValueError(fmove out of bounds: {x}, {y})为什么这样能防住浮点坐标大多是算出来的比如平均位置、中心倾向一旦进了np的切片就会炸。入口校验不仅解决了崩溃还能让你在ValueError的堆栈里看到是哪个策略输出了非法坐标直接定位到算法层。5.2 AI一直往同一块地方落子棋盘一块区域被填满现象自对弈过程里某一方的落子全部集中在棋盘一角其他区域全是空白。原因forbidden矩阵的错误累加——在一些实现里对方“提子”后应当从forbidden清除位置但如果你的提子逻辑没实现被提掉的位置还留在forbidden里导致自己以为那里不能下棋盘越打越小。解决在“无气提子”缺失的情况下至少对forbidden做“冷却”处理每一回合结束把forbidden里超过 N 回合的标记清空。def decay_forbidden(self, decay_after5): self.forbidden[self.forbidden 0] - 1 self.forbidden[self.forbidden 0] 0这个技巧是有意偏离严格规则的“工程妥协”幻影围棋里你只能通过对方尝试落子来探知位置但如果项目没实现提子逻辑forbidden只增不减的局面必然导致搜索空间急剧缩小。加一个衰减机制相当于给AI一个“遗忘”的能力对局会更像真正的幻影围棋因为真实玩家也会忘记对方棋子的位置。5.3 MCTS搜索深度永远只有一层一直在根节点反复展开现象无论simulations设多大搜索树都长不高每个节点最多只有一个子节点。原因legal_moves()返回的动作没有做随机化每次都返回同一个顺序列表导致expand()每次都只展开列表的第一个动作。这是最常见也最隐蔽的问题——不是算法错误而是动作生成顺序把搜索锁死了。解决在legal_moves()返回前random.shuffle(moves)或者在expand()里随机选一个未展开动作。def expand(self): unvisited [m for m in self.board.legal_moves() if m not in self.children] if unvisited: move random.choice(unvisited) self.children[move] PhantomNode(self.board.copy())这里的逻辑MCTS的节点展开需要多样性没有随机化的legal_moves相当于让选择阶段失去意义。这个坑在传统围棋里不怎么常见因为棋盘大、动作多天然有变化幻影围棋9路棋盘合法点少不洗牌的话搜索树会退化成一个链表。5.4 明明搜索了500次胜率却和随机下棋没区别现象MCTS胜率上不去和random策略对局也只有55%左右。原因simulate()阶段没有使用weighted_playout而是纯随机或者在回传阶段把胜负结果传错了方向——自己赢传成对方赢。第二种情况更隐蔽因为棋力不会崩只是变笨。解决在backpropagate里加一行调试日志打印节点路径上的胜负方向。def backpropagate(self, winner): node self while node is not None: if winner self: node.wins 1 node.visits 1 node node.parent判断逻辑获胜方只有两种self或opponent这里的self永远指当前根节点视角。写日志时一定同时打印winner和当前节点是第几层这样能快速看出回传方向是否一致。否则你会花大量时间调参数最后发现是胜负判断反了纯属白忙活。5.5 对局结束时卡在无限循环里现象is_game_over永远返回FalseAI 在只有两个空白点的情况下反复下子。原因终局判定只检查“是否有空白点”但幻影围棋里如果自己的合法动作满了而对方还有动作双方轮换就可能卡死——一方无子可下另一方还在下。解决加一个“连续跳过”机制当某一方连续skip_limit次无动作可下时直接终局。if moves_without_play 2: return True这个修法和真实围棋的“虚着”概念很接近是幻影围棋规则在工程里的常见妥协。如果没有这个限制AI会在棋盘满之前一直互相“让先”进程永远走不到结算。6. 给幻影围棋AI提速缓存复用与落子序列可视化6.1 用哈希缓存避免重复搜索同一局面在幻影围棋里搜索树的节点数量比想象中涨得快因为每个节点还带着own和forbidden两个矩阵。即使加权随机模拟省了不少算力重复局面仍然大量存在——尤其在前几十步动作空间大但有效动作其实不多。我的做法是给每个节点的状态算一个哈希存到一个 dict 里命中的局面直接复用评估结果。class TranspositionTable: def __init__(self): self.table {} def hash_state(self, own, forbidden): own_bytes own.tobytes() forbidden_bytes forbidden.tobytes() return hash((own_bytes, forbidden_bytes)) def lookup(self, own, forbidden): key self.hash_state(own, forbidden) return self.table.get(key) def store(self, own, forbidden, value): key self.hash_state(own, forbidden) self.table[key] value这里的性能收益很明显9路棋盘跑500次模拟时不加缓存大概需要3到5秒一步加了之后能压到2秒以内。注意hash()在Python进程间不保证一致但同一个进程内是稳定的所以这个缓存只在单局自对弈里有效。跨进程使用的话建议换成hashlib.md5()代价是速度慢一些但一致性好。6.2 用落子序列可视化判断AI的行为模式调参过程中只看胜率数字是不够的。我会把对局落子序列导出成一张“时间序棋盘”每一手标记序号看AI是不是真的在扩张、在防守还是纯粹在摆烂。这个工具逻辑很简单维护own和forbidden两个数组每步落子之后把序号写进对应位置。def visualize_game(records, size9): board np.full((size, size), ., dtypestr) for i, (move, side) in enumerate(records): x, y move board[x][y] str(i % 10) print(\n.join( .join(row) for row in board))这里有个实际用途如果你把c从1.4调到2.5后胜率上升但落子序列显示AI总是往自己棋子上叠那就是你的棋形价值函数出了问题而不是搜索带宽不够。可视化能让你在玄学和科学之间找到一条清晰的判断线——不再靠着胜率猜直接看行为模式。6.3 对局记录序列化把数据喂给统计脚本自对弈多了以后我习惯把对局记录存成JSON后面用Pandas做统计分析比如不同参数下的平均步数、胜负分布、落子位置热力图。这个习惯帮我发现了一个单看胜率发现不了的问题——AI 在中盘以后落子特别慢因为forbidden矩阵越来越大legal_moves()的遍历成本变高。records {game_id: 128, size: 9, moves: moves, winner: winner} with open(game_records.jsonl, a) as f: f.write(json.dumps(records) \n)JSONL的好处是每行一条记录追加写方便后面用pandas.read_json(..., linesTrue)直接读回 DataFrame。统计落子位置热力图时可以直接把moves列表展开成坐标对加减乘除都不用手工解析。这个环节不是锦上添花——当你把参数从c1.4调到2.5时热力图的变化会让你更快理解搜索带宽和探索倾向之间的关系。我个人的习惯是每次调参只改一个变量跑20局自对弈把落子序列存下来再一起对比。这样做的好处是出了问题你知道是哪个变量导致的而不是五个参数搅在一起无法归因。从“能跑”到“能调”再到“知道为什么这么调”这条路我踩了不少坑希望这些记录能帮你少走一段弯路。本文还有配套的精品资源点击获取