news 2026/10/26 8:32:46

幻影围棋:基于Python和MCTS的幽灵手策略围棋引擎实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
幻影围棋:基于Python和MCTS的幽灵手策略围棋引擎实现

简介:一个基于Minigo的幻影围棋智能对弈项目,使用Python实现,面向围棋AI爱好者与开发者。项目融合卷积神经网络与蒙特卡洛树搜索,支持人机对战与两个AI相互博弈(机机博弈),可用于研究AI棋力进化、验证强化学习策略,或作为入门Minigo架构的实践范例。资源包为zip格式,共18个文件,主要包括14个Python源码(涵盖网络定义、特征提取、对弈逻辑、MCTS搜索、GUI人机界面等),以及TensorFlow模型权重文件(data/meta/index)和说明文档,整体仅约1.47MB,结构轻量。当前已有926人学习下载,适合想通过简洁代码理解围棋AI原理、二次开发或进行棋力对比测试的读者;代码量精炼、依赖简洁,适合快速上手。通过机机对弈和随机对手测试,可直观观察AI策略提升过程,并利用自带工具修改与扩展,是一个可运行、可实验的完整围棋AI小项目。

1. Ghost-master 幻影围棋是什么:一个会让 AI 下“虚着”的 Python 围棋引擎

把 Ghost-master 拆开看,“Ghost”指的是围棋里的“幽灵手”——那些不按局部常型走、故意脱先或虚点的试探着;“master”是这套策略要达成的目标,让 AI 不止会按胜率下棋,还会像人类高手一样制造味道、弃子争先。幻影围棋就是把这个思路落到 Python 代码里的一套围棋引擎。它解决的不是“AI 有多强”的问题,而是“AI 怎么做出不贪吃、留余味的选择”。适合想自己动手写博弈程序、研究蒙特卡洛树搜索(MCTS)但又不想一上来就碰深度学习的从业者。

2. 幻影围棋的底层设计:从棋盘表示、MCTS 到“幽灵手”如何生成

2.1 棋盘表示:用 numpy 数组和邻接矩阵建模 19 路盘面

做围棋引擎第一步是选棋盘数据结构。常见做法是用一个二维数组存盘面状态,我用 numpy 的 int8 矩阵,0 表示空、1 表示黑、2 表示白。19 路的棋盘就是np.zeros((19, 19), dtype=np.int8),操作直观,后期要做特征提取也方便。

import numpy as np EMPTY, BLACK, WHITE = 0, 1, 2 class GoBoard: def __init__(self, size=19): self.size = size self.grid = np.zeros((size, size), dtype=np.int8) self.ko = None # 劫争位置,None 表示当前没有劫 self.last_move = None # 记录上一手坐标,用于劫争判断 self.pass_count = 0 # 连续 pass 次数,双方都 pass 则终局 def is_on_board(self, row, col): return 0 <= row < self.size and 0 <= col < self.size def neighbors(self, row, col): for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)): nr, nc = row + dr, col + dc if self.is_on_board(nr, nc): yield nr, nc

这里我把“气”的定义拆成了两个辅助方法:neighbors返回四个相邻点,is_on_board过滤边界。别小看这两个函数,后面所有提子、死活判断都依赖它们。

相邻点的集合本质上就是一张四邻域邻接矩阵,只要棋盘尺寸固定,这张矩阵可以提前算一次存下来,不用每次落子都重新算边界。用数组切片做可视化也很顺手,比如grid[3:6, 10:13]可以直接看局部棋形,这在调试幽灵手生成逻辑时非常有用。

2.2 为什么选 MCTS + 随机模拟,而不是直接上深度学习

幻影围棋的核心是“在不确定最优手的情况下,愿意下试探手”。这个诉求和蒙特卡洛树搜索天然契合。MCTS 的四步——选择、扩展、模拟、回传——本来就是在用大量随机对局估算每个落子的胜率,随机模拟本身就有“试错”的味道,加一层幽灵手策略很自然。

深度学习路线当然棋力更高,但训练一个监督模型需要大量棋谱数据,自对弈强化学习又要分布式训练环境,这对多数人来说太重了。Ghost-master 的定位不是碾压职业棋手,而是让学习者能看懂每一步为什么被选出来。MCTS 的决策过程完全透明,节点里的胜率和访问次数都可以打印出来复盘。

随机模拟的下限是“会下”,上限取决于模拟次数和选择策略。我一般先用 200 次模拟跑通流程,再逐步加参数。这个思路和调机器学习模型是一样的:先保证管线不报错,再谈效果。

2.3 幽灵手机制:三段式虚着判定

“幽灵手”在这个项目里不是玄学,而是一段可量化的策略代码。我给 Ghost 的虚着定义了三个触发条件:脱先、弃子、点方。

脱先指局部战斗中主动不接应,去抢更大的场;弃子指故意送掉少数棋子换取先手;点方指在对方棋形的要点上落一颗虚点,制造味道。实现时用一段三元判定函数:

def ghost_move(board, color, win_rate, move_count): """在优势局面下生成幽灵手: 1. 优势明显且战况不激烈时脱先 2. 局部气紧时弃子转头 3. 中盘阶段下在对方弱侧要点""" if move_count < 30: return None # 布局阶段不下虚着 if win_rate < 0.55: return None # 劣势局面不下虚着,容易翻车 if board.last_move and board.pass_count == 0: r, c = board.last_move # 对方刚下完棋,如果己方胜率够高,尝试脱先 candidates = board.point_candidates(color, mode="away") if candidates: return random.choice(candidates) return None

point_candidates会扫描棋盘,把“离己方最强棋形较远、离对方薄弱棋形较近”的点列为候选。这个函数的细节是幽灵手质量的关键:纯粹随机选虚点等于送棋,必须让虚点落在对方弱侧附近,才有后续利用。我在 4.3 节会展开讲怎么给候选点打分。

3. 从零搭起最小可运行引擎:GTP 命令行与自对弈代码

3.1 初始化棋盘与落子:坐标转换和提子实现

围棋坐标习惯用“字母列 + 数字行”表示,比如棋盘左上角是 A19(在 SGF 坐标里通常是aa)。引擎内部用行列索引更高效,所以第一步是坐标转换,我用 argparse 接命令行输入,解析成内部坐标。

import argparse def coord_to_rc(coord: str, size: int = 19): """把 GTP 风格坐标 A1 转成 (row, col),A 对应第 0 列""" coord = coord.strip().upper() col = ord(coord[0]) - ord('A') if col >= 8: # 跳过 I 列,围棋坐标没有 I col -= 1 row = size - int(coord[1:]) return row, col def rc_to_coord(row: int, col: int, size: int = 19): col_letter = chr(ord('A') + col + (1 if col >= 8 else 0)) return f"{col_letter}{size - row}"

注意 GTP 协议里没有 I 列,A 到 H 对应 0 到 7,跳过 I 后 J 对应 8。这个细节不处理,所有坐标都会错位一格。我在调试前期就因为这一个小点翻过车,棋盘看起来只差一行,落子全歪。

落子时要处理四件事:检查点是否为空、落子、提掉对方无气块、判断己方是否无气(自杀则禁止)。提子算法用 flood fill 找连通块:

def group_and_liberties(self, row, col): """返回包含 (row, col) 的连通块集合及其气数""" color = self.grid[row][col] if color == EMPTY: return set(), 0 group = set() libs = set() stack = [(row, col)] while stack: r, c = stack.pop() if (r, c) in group: continue group.add((r, c)) for nr, nc in self.neighbors(r, c): if self.grid[nr][nc] == color: stack.append((nr, nc)) elif self.grid[nr][nc] == EMPTY: libs.add((nr, nc)) return group, len(libs)

这里的stack用列表模拟栈,天然就是深度优先搜索,不需要额外引队列模块。连通块集合用set存,避免重复访问;气数则是所有相邻空点的集合大小。提子时先提对方无气块,再检查己方落子后是否无气,顺序反了会出现“自杀但实际能提子”的误判。

3.2 死活判定与终局数子:用 flood fill 处理区域归属

对弈双方都 pass 后进入终局数子。最小引擎不需要实现完整日本规则,只需要一个能判断胜负的数子逻辑:扫描全盘,对每个空点做 flood fill,看它邻接哪些颜色的棋子。

def score(self): """简单数子:只统计空点归属和提子数,不处理贴目""" visited = set() black_area = white_area = 0 for r in range(self.size): for c in range(self.size): if self.grid[r][c] != EMPTY or (r, c) in visited: continue stack = [(r, c)] area = set() touch_black = touch_white = False while stack: curr = stack.pop() if curr in area: continue area.add(curr) for nr, nc in self.neighbors(*curr): if self.grid[nr][nc] == EMPTY: stack.append((nr, nc)) elif self.grid[nr][nc] == BLACK: touch_black = True else: touch_white = True visited |= area if touch_black and not touch_white: black_area += len(area) elif touch_white and not touch_black: white_area += len(area) return black_area, white_area

这套数子逻辑有两个已知偏差:不处理双活,不处理贴目。对自对弈内部测试够用,因为两个引擎用的是同一套规则,胜负判定是公平的。真要比较棋力,还是要用 GTP 协议接 GoGui 这类图形界面,让外部工具做最终判定。

3.3 跑通第一局自对弈:命令行参数与输出

自对弈入口我习惯做成一个独立脚本,方便后续批量测试参数。命令行用 argparse 接收棋盘尺寸、模拟次数、虚着概率、对局数。

if __name__ == "__main__": parser = argparse.ArgumentParser(description="Ghost-master 自对弈入口") parser.add_argument("--size", type=int, default=19) parser.add_argument("--sims", type=int, default=200) parser.add_argument("--ghost", type=float, default=0.15) parser.add_argument("--rounds", type=int, default=2) args = parser.parse_args() for i in range(args.rounds): board = GoBoard(args.size) winner = play_game(board, rounds=args.sims, ghost_prob=args.ghost) print(f"round {i + 1}: winner={winner}")

play_game内部是两个引擎交替落子直到双方 pass,落子顺序用move_count的奇偶判断。这一步跑通后,你就有了一个最原始但完整的围棋 AI 闭环。我在这里第一次真正理解“神经网络不是必须的”——纯随机模拟的引擎虽然棋力弱,但每一步决策都有完整记录,这正是学习想要的。

4. 把棋力从“会下”推到“能看”:UCT、模拟次数和棋形特征

4.1 UCT 公式与探索系数 C:为什么 1.4 是常见起点

纯随机模拟的 MCTS 每个节点访问次数差不多,决策质量很差。要让搜索更有方向,得用 UCT(Upper Confidence Bound for Trees)给每个落子打分。

import math import random class Node: __slots__ = ("move", "parent", "children", "wins", "visits") def __init__(self, move, parent=None): self.move = move self.parent = parent self.children = [] self.wins = 0 self.visits = 0 def uct_score(node, total_visits, c=1.4): """返回 UCT 分数,未访问过的节点给无穷大,保证先扩展一次""" if node.visits == 0: return float("inf") exploitation = node.wins / node.visits # 胜率 exploration = c * math.sqrt(math.log(total_visits) / node.visits) return exploitation + exploration

探索系数 C 控制“多尝试冷门点”和“多走当前最优”的平衡。C 太大,AI 会在明显不重要的地方反复浪费模拟;C 太小,AI 会陷入局部最优。1.4 是很多围棋引擎的默认起点,原因是胜率通常在 0 到 1 之间,log 项在访问次数增长后衰减很快,1.4 能保证前几十轮模拟还有足够探索。如果对手明显更强,可以把 C 降到 1.0 以下,让 AI 更专注;如果引擎总是走出“看不懂”的棋,说明 C 偏高,往上调访问量比继续加 C 更有效。

我见过不少人直接把 C 调到 3.0,以为探索越多棋力越强,结果 AI 在开局疯狂点角——因为 UCT 给了访问次数少的点过高加成。这类问题不是参数玄学,是公式里数学性质决定的。

4.2 模拟次数和虚着频率怎么配:一张参数表

MCTS 里模拟次数直接决定每手棋的思考时间。200 次模拟在 19 路上几乎等于随机走,1000 次才有基本的棋形意识,5000 次以上才算“能看”。但模拟次数不是唯一变量,幽灵手的触发频率同样影响风格和胜率。

下面是 Ghost-master 最常见的参数组合,我按“快棋 / 慢棋 / 幻影特色”三种场景给推荐值:

场景模拟次数探索系数 C虚着频率预期表现
快棋测试2001.40.05能下完一盘,经常无谓送子
标准棋力10001.40.10有基本死活意识,不主动送子
幻影风格30000.80.25中盘会脱先,胜率略降但棋形丰富
极限推演80001.00.08慢但稳健,适合复盘研究

虚着频率超过 0.3 会出大问题:AI 在局部吃棋的关键时刻突然脱先,等于把优势送回去。幽灵手必须绑定胜率条件,我在 2.3 节里设置的是胜率 0.55 以上才触发。很多人改参数只改频率不改胜率阈值,这是幽灵手翻车的头号原因。

4.3 用“相邻棋形计数”给随机模拟加一点棋感

纯随机模拟的另一个问题是“看不懂棋形”,导致大龙对杀时常判断失误。一个轻量改进是在模拟阶段给落子点加权——优先落在己方棋子旁边,其次是对方棋子旁边,最后才是空旷处。

def heuristic_playout_move(board, color, rng=random): """随机模拟中的落子选择:带棋形偏好的加权采样""" candidates = [] weights = [] for r in range(board.size): for c in range(board.size): if board.grid[r][c] != EMPTY: continue w = 1.0 for nr, nc in board.neighbors(r, c): if board.grid[nr][nc] == color: w += 2.0 # 己方相邻,延续棋形 elif board.grid[nr][nc] != EMPTY: w += 1.5 # 对方相邻,保持接触 candidates.append((r, c)) weights.append(w) return rng.choices(candidates, weights=weights, k=1)[0]

rng.choices按权重做带放回采样,权重越高被选中的概率越大。这个启发式没有引入任何外部库,只用了 numpy 以外的标准库 random。效果上,它让蒙特卡洛模拟的终局分布更接近真实对局——模拟中的棋不会再完全散落。

注意这里的权重是硬编码的 2.0 和 1.5,在不同盘面风格下可以直接调。想省事也可以把启发式只用在模拟阶段,不作用在 UCT 选择阶段;作用在 UCT 阶段会让决策更激进,但胜率波动会更大,需要分别测。

5. Ghost-master 实战避坑:五条新手最容易翻车的记录

5.1 提子后气算错,导致死活判断集体失灵

现象:AI 吃掉一块棋之后,旁边原本没气的棋子突然又能下了,胜率计算立刻紊乱。

原因:提子时只删除了被提的棋子坐标,没有重新计算相邻连通块的剩余气。旧棋盘数据残留,flood fill 扫到已经被提的空点时,把它们当成了气。

解决:提子后必须强制执行一次“残留点清理 + 邻块气数刷新”。我用一个独立函数remove_group,先记录被提块的所有坐标,把它们置为 EMPTY,再对相邻的对方棋子重新调用group_and_liberties。顺序不能反过来,先刷新气再清空坐标,会访问到无效索引。

5.2 自对弈进入疑似“死循环”:打劫规则没处理

现象:左右互搏时,AI 在同一位置反复提子,对局卡住不走下一个点。

原因:围棋的劫争规则禁止立即提回同一形状。我的第一版引擎没有记录“上一手是否提子、提的哪个位置”,导致白方提劫后黑方马上又提回,双方无限循环。

解决:在move方法里记录self.ko,规则是“如果上一手只提了对方一颗子,且当前手落在被提子位置,禁止落子”。注意 ko 点是“被提掉的位置”,不是“对方刚落的位置”。我用一个简单判断:上一手的整个提子集合长度如果为 1,说明是单子提劫,把那个坐标存为 ko 点。

5.3 Python 环境和依赖安装:镜像站与 venv 的坑

现象:代码本地跑得好好的,换台机器import numpy直接报错 ModuleNotFoundError,或者pip install numpy安装了但 import 还是失败。

原因:最常见的是系统 Python 和虚拟环境混用。很多入门者直接在系统环境里pip install,下次打开终端装的包还在,但 IDE 里选择了另一个解释器。还有一类是 pip 源慢导致下载到一半中断,装了个不完整的包。

解决:每个项目建独立 venv,用python -m venv .venv再激活,所有依赖写进requirements.txt。我多花几秒做这件事,省掉了后面一整天的环境排查。安装慢的问题用国内镜像源通常能解决,但注意镜像站里的包版本可能不全,装完要跑一遍最小导入测试。

5.4 幽灵手反击自身:虚着频率越高,胜率不升反降

现象:把虚着频率从 0.1 调到 0.3,本以为棋风会更“幻影”,结果 20 局自对弈胜率反而掉了近三成。

原因:虚着的收益不是线性的。优势局面下偶尔脱先,对方会因为接应慢而亏损;但优势局面下频繁脱先,等于主动放弃局部优势,对方只要正常应对就能逐步扳平。幽灵手的收益函数只看“是否在优势方”,没有看“当前局部是否正在激烈对杀”。

解决:给幽灵手上三道锁——胜率必须在 0.55 以上、当前局部不存在双方紧气对杀、虚着点必须落在对方弱侧。三条同时满足才允许虚着。这之后虚着频率哪怕调到 0.25,胜率也只是轻微下降,棋形反而丰富很多。

5.5 性能灾难:每次模拟都复制整个棋盘

现象:模拟次数加到 3000 后,一手棋要十几秒,完全没法做参数扫描。

原因:模拟阶段递归落子时,我一开始用board.copy()深拷贝整个 19 路棋盘传给下一层递归,一次模拟要创建几百个副本,内存和 CPU 双双爆掉。

解决:改成“落子 + 悔棋”的回滚式模拟。每次模拟开始前记录当前手数,模拟内部用临时标记记住提子坐标,模拟结束把提掉的子放回去。这样整个模拟过程只操作一份棋盘对象,性能提升一个数量级。9 路棋盘可能无所谓,19 路必须做这个优化。

6. 验证棋力与盲棋模式:自对弈胜率、复盘一手棋的技巧

6.1 自对弈 20 局统计胜率:同引擎内测

改完任何参数,第一件事是跑 20 局自对弈,而不是拿棋盘肉眼看一两局。自对弈能消除单局随机性,胜率低于 45% 的参数组合直接淘汰。

def selfplay(engine_a, engine_b, rounds=20, size=19): """两个引擎同规则自对弈,返回黑胜率""" results = {"B": 0, "W": 0} for _ in range(rounds): board = GoBoard(size) winner = play_game(board, engine_a, engine_b) results[winner] += 1 return results["B"] / rounds

注意引擎 A 和引擎 B 可以相同,也可以故意设置不同参数。同引擎自对弈测的是“策略是否自洽”,不同参数对弈测的是“哪个配置更强”。前者验证代码正确性,后者验证参数优劣。

6.2 让 Ghost 下盲棋:屏蔽棋盘后的记忆局面验证

幻影围棋最特别的验证方式,是让引擎在完全看不到棋盘输出画面的情况下,只依赖内部状态下棋。做法是把落子后的棋盘打印全部关掉,给引擎的输入只有坐标记录,每走 10 手再手动校验一次board.grid和落子记录是否一致。

我习惯让引擎每 20 手输出一次“内部记忆快照”和“真实盘面对比”,偏差为 0 才说明状态管理没出问题。这个验证对提子、劫争、回滚这三块的正确性非常有效——一个逻辑错误通常在 30 手内就会让内部棋形和真实棋形产生偏移。

现在每改一个参数,我都会先跑 10 局自对弈,再盲棋复盘一手棋。复盘时只看那一手的 UCT 分数、访问次数和落子位置,基本能判断它是搜索出来的好手,还是启发式的偶然。这个习惯帮我省掉了大量“调参靠猜”的无效工作,希望帮到你。

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

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

Linux线程基础与POSIX多线程编程实践

1. Linux线程基础概念解析在Linux系统中&#xff0c;线程是程序执行流的最小单元&#xff0c;也是操作系统能够进行运算调度的最小单位。与重量级的进程相比&#xff0c;线程最大的特点是共享相同的地址空间和系统资源&#xff0c;这使得线程间的通信和数据共享变得非常高效。1…

作者头像 李华
网站建设 2026/10/26 8:29:36

AI接口性能优化:从模型到架构的实战指南

1. 为什么你的AI接口响应慢如树懒&#xff1f; 最近在技术社区看到不少开发者吐槽自己的AI接口响应速度堪比树懒移动&#xff0c;这种性能瓶颈在实际业务中确实令人抓狂。作为一个经历过多次接口优化战役的老兵&#xff0c;我想分享几个关键症结和解决方案。 AI接口的延迟问题…

作者头像 李华
网站建设 2026/10/26 8:28:55

代谢回路工程实战:在大肠杆菌中构建动态调控的萜类合成系统

引言 提到Loop Engineering&#xff0c;可能很多做生物技术或者合成生物学的朋友这几年已经听过不少次了。简单说&#xff0c;这不是某个单一工具或者某个软件插件&#xff0c;而是一整套围绕“代谢回路/环路设计、构建、调试和优化”的工程方法论。它解决的核心问题有两个&…

作者头像 李华
网站建设 2026/10/26 8:27:43

图解AI应用架构设计:模型、数据流与容错策略实战

做AI应用开发这几年&#xff0c;我最大的感触是&#xff1a;很多团队不是死在模型能力上&#xff0c;而是死在架构设计上。模型选错了可以换&#xff0c;Prompt写得不好可以调&#xff0c;但架构一旦定错了&#xff0c;后面每一步都在还债——接口乱成一团、数据流理不清、上线…

作者头像 李华
网站建设 2026/10/26 8:26:16

无Tool Calling的Agent架构:用结构化JSON协议实现可控多步决策

做 Agent 做久了&#xff0c;工具调用的光环其实是有期限的。我之前把业务一股脑挂到原生 function calling 上&#xff0c;图它格式稳定、链路短&#xff0c;结果到后期被折腾得不轻&#xff1a;“无 Tool Calling 的结构化通用 Agent”这个思路&#xff0c;就是我在这种状态下…

作者头像 李华
网站建设 2026/10/26 8:26:12

Android Intent机制详解:显式与隐式Intent实战指南

1. Intent机制深度解析&#xff1a;Android开发者的导航利器 在Android应用开发中&#xff0c;Intent就像一张万能通行证&#xff0c;它决定了应用内外的页面跳转、功能调用和数据传递。作为系统级消息传递机制&#xff0c;Intent分为显式和隐式两种类型&#xff0c;理解它们的…

作者头像 李华