news 2026/9/28 13:00:45

Python实现幻影围棋AI:不完全信息博弈与蒙特卡洛树搜索实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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, size=9): self.size = size self.own = np.zeros((size, size), dtype=np.int8) self.forbidden = np.zeros((size, size), dtype=np.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 -20

is_game_over的判定逻辑通常是:当own和forbidden覆盖了所有交叉点,也就是棋盘上没有空白位置可以再下,就直接进入结算。结算时按“己方棋子数 + 贴目”来算,不再数空。这种简化最直接的影响是:对局会偏战斗、偏围地,因为空白点一旦被填满就结束了,所以双方都会倾向于尽快占满可见的空白。如果你想让棋力更强,可以考虑把is_game_over改成“连续若干回合无人落子再终局”,效果接近传统围棋的单官收完。

4. 给AI装上搜索:蒙特卡洛树搜索的适配与参数调优

4.1 隐藏信息下的MCTS:信念状态与随机模拟的取舍

幻影围棋的搜索和普通围棋最本质的区别在于:你在搜索树里展开的每一个节点,都不是一个确定局面,而是一个“信念状态”——你对自己棋子位置的确定认知 + 对对方棋子位置的估计分布。最常见的从业方案不是去精确推信念分布,而是用蒙特卡洛模拟来近似:从当前己方视角开始,每次随机猜测对方棋子的可能位置,然后按完整棋盘的规则去走。

def mcts_search(board, simulations=200): 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()去抽样。这是工程上最常见的做法:与其花算力维护一个巨大的隐藏信息模型,不如让模拟过程去对冲不确定性。simulations=200是低于常规的参数,实际跑9路幻影围棋时,我建议至少给到500,因为隐藏信息导致模拟方差比完整信息围棋大得多,样本量不够时选出的落子几乎和随机差不多。

4.2 UCB公式的三个必调参数

MCTS 的核心在best_child()里的UCB公式。这个公式有一个常数c控制“探索”和“利用”的平衡,在幻影围棋里这个常数必须往大了调。

def ucb_score(self, node, c=1.4): exploitation = node.wins / (node.visits + 1e-9) exploration = c * math.sqrt(math.log(self.visits + 1) / (node.visits + 1e-9)) return exploitation + exploration

参数说明:c=1.4是传统围棋常被推荐的起点,但在幻影围棋里,由于信息隐藏导致胜率估计噪声大,我会把c提到2.0~2.5。你观察到的现象应该是:c太小(比如0.5)时,AI 会反复下同一个位置,不去探索其他可能;c太大(比如5.0)时,AI 落子接近随机。另外visits初始值那种1e-9的防零处理不能省,否则首次访问的子节点会直接除零。

4.3 模拟策略:随机还是加权随机

搜索的效果一半看选择策略,一半看模拟(playout)策略。完整信息围棋里可以用快速走子策略,3×3 区域内优先打吃、逃跑;幻影围棋里由于看不见对方的棋,快速走子策略大部分失效。我用下来最不坏的选择是加权随机——空点上越靠近己方已有棋子的位置权重越高。

def weighted_playout(board, playout_moves=30): 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, weights=weights)[0] board.play(move) return board.winner()

这段代码的计算逻辑:每个合法落子点的权重取“到最近己方棋子的曼哈顿距离的反比”,也就是离自己棋子近的位置更可能被模拟下到。这个策略的思想是——在看不见对方棋子的情况下,抱团扩张比天女散花更容易形成确定的地。实际测试下来,加权随机比纯随机的模拟在对局胜率上有大约5~8个百分点的提升,在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(f"move out of bounds: {x}, {y}")

为什么这样能防住:浮点坐标大多是算出来的(比如平均位置、中心倾向),一旦进了np的切片就会炸。入口校验不仅解决了崩溃,还能让你在ValueError的堆栈里看到是哪个策略输出了非法坐标,直接定位到算法层。

5.2 AI一直往同一块地方落子,棋盘一块区域被填满

现象:自对弈过程里,某一方的落子全部集中在棋盘一角,其他区域全是空白。原因:forbidden矩阵的错误累加——在一些实现里,对方“提子”后应当从forbidden清除位置,但如果你的提子逻辑没实现,被提掉的位置还留在forbidden里,导致自己以为那里不能下,棋盘越打越小。解决:在“无气提子”缺失的情况下,至少对forbidden做“冷却”处理:每一回合结束,把forbidden里超过 N 回合的标记清空。

def decay_forbidden(self, decay_after=5): 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永远返回False,AI 在只有两个空白点的情况下反复下子。原因:终局判定只检查“是否有空白点”,但幻影围棋里如果自己的合法动作满了,而对方还有动作,双方轮换就可能卡死——一方无子可下,另一方还在下。解决:加一个“连续跳过”机制,当某一方连续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, size=9): board = np.full((size, size), ".", dtype=str) 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(..., lines=True)直接读回 DataFrame。统计落子位置热力图时,可以直接把moves列表展开成坐标对,加减乘除都不用手工解析。这个环节不是锦上添花——当你把参数从c=1.4调到2.5时,热力图的变化会让你更快理解搜索带宽和探索倾向之间的关系。

我个人的习惯是:每次调参只改一个变量,跑20局自对弈,把落子序列存下来,再一起对比。这样做的好处是,出了问题你知道是哪个变量导致的,而不是五个参数搅在一起无法归因。从“能跑”到“能调”,再到“知道为什么这么调”,这条路我踩了不少坑,希望这些记录能帮你少走一段弯路。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/28 13:00:14

pixi global uninstall 命令完全指南:卸载全局环境与底层实现剖析

开发工具CLI包管理器任务调度 【免费下载链接】pixi Powerful system-level package manager for Linux, macOS and Windows written in Rust – building on top of the Conda ecosystem. 项目地址&#xff1a; https://gitcode.com/gh_mirrors/pi/pixi 点击查看 免费下载 导…

作者头像 李华
网站建设 2026/9/28 12:59:58

pixi:Windows上Python环境管理的新利器,替代conda与venv

在Windows上折腾Python环境&#xff0c;可能是很多人的心头之痛。系统自带的Python版本老旧&#xff0c;项目A要3.9项目B要3.11&#xff0c;装个numpy还能碰上MSVC编译报错&#xff0c;卸载重装不是一次两次了。早几年大家靠conda&#xff0c;后来用venv加pip&#xff0c;但这两…

作者头像 李华
网站建设 2026/9/28 12:59:51

ESP32驱动GC9D01 TFT屏实现动态眼睛动画

1. 项目概述&#xff1a;为什么一只“会转动的眼睛”值得花5分钟折腾&#xff1f;你有没有试过把一块GC9D01屏幕焊在ESP32开发板上&#xff0c;通电后只看到一片灰白——连个“Hello World”都不显示&#xff1f;我第一次接这块屏时也卡了整整两天&#xff0c;烧了三块ESP32-WR…

作者头像 李华
网站建设 2026/9/28 12:57:14

从“无标题”到上线:需求分析、技术选型与MVP落地全流程

“无标题”这三个字&#xff0c;说实话&#xff0c;刚看到这个项目名的时候我愣了一下。但仔细想想&#xff0c;这恰恰是很多项目最真实的状态——需求还没理清、方向还没定死、名字自然也还没想好。这个阶段往往是项目从零到一最混乱、也最容易走偏的时候。这篇东西就是写给正…

作者头像 李华
网站建设 2026/9/28 12:56:58

Flutter鸿蒙适配:at_server_status 去中心化身份监控引擎实战

前阵子接了个挺有意思的活儿&#xff1a;把 Flutter 生态里用于监控 protocol 去中心化身份服务器状态的第三方库 at_server_status 适配到鸿蒙系统上。说实话&#xff0c;接之前我以为这就是改改依赖、跑个 flutter build 的事儿&#xff0c;真正动手才发现&#xff0c;从依赖…

作者头像 李华