☰
MCTS+深度学习实现Hex游戏AI:核心原理与训练避坑实战
2026/10/11 10:48:53 网站建设 项目流程

简介:这是一份基于蒙特卡洛树搜索(MCTS)与深度学习技术实现的Hex(六角棋)游戏AI完整工程,主要面向希望入门游戏AI或强化学习方向的Python开发者。项目以Python构建,核心将MCTS搜索与神经网络策略/价值评估相结合,通过自我对弈生成数据并训练模型,能够自动完成棋局决策。压缩包共12个文件,含8个Python源码、2个CSV数据文件(用于训练/测试棋局记录)、1个说明文档及配置文件;源码按游戏规则与状态管理、MCTS搜索、神经网络定义、AI玩家、训练脚本、可视化等模块清晰拆分,目录结构利于按需阅读与二次开发。已有239人学习使用。通过该资源可深入学习MCTS的模拟、选择、扩展、备份完整流程,理解策略网络与价值网络在棋类AI中的具体作用,并可直接运行或调整超参数复现Hex游戏的人机对战与自我对弈训练,配合CSV数据快速验证训练效果。适合作为人工智能、博弈决策及Python游戏开发的实践参考。

1. 用 MCTS 加深度学习做 Hex 游戏 AI:先想清楚再动手

做 Hex 游戏 AI 最反直觉的一点是:棋盘越大,纯蒙特卡洛越不中用。19 路的 Hex 棋盘合法落子空间比围棋还大,靠随机模拟去估胜率,模拟到天荒地老也估不准。我最初拿纯 MCTS 跑 11 路棋盘,胜率卡在 60% 上不去,换了一版带策略网络的实现后,同算力下直接推到 85% 以上。这套 AI-Hex 项目本质就是把 AlphaGo 那套 MCTS + 深度学习的思路压缩到 Hex 上:策略网络告诉 MCTS 往哪儿搜值得,价值网络告诉它局面到底行不行,两者拼起来,AI 就不再是「有手就行」的随机走子器。资源适合两类人:一类是刚啃完强化学习理论、想在 Python 里跑通完整管线的同学,另一类是做棋类 AI 但不想从零造轮子的开发者。它能给你一套能改、能训、能评估的完整代码骨架,而不是一个跑完就忘的 Demo。

2. 选型与原理:为什么纯蒙特卡洛在 Hex 上走不远

2.1 Hex 的规则与搜索空间特征

Hex 游戏规则一句话就能说清:六边形棋盘,双方轮流落子,先手方要把左上和右下连通,后手方要把右上和左下连通。规则简单,但搜索空间一点都不简单。n 路棋盘有 n 平方个格点,11 路就是 121 个落子点,19 路是 361 个,和围棋 19 路棋盘同一量级。

我一开始天真地以为棋盘小一半,搜索难度也小一半。实际跑下来完全不是这么回事。Hex 没有围棋那样的「吃子」机制,棋子落上去就不动,所以每个格点只存在三种状态:空、黑、白。看似状态数少了,但连通性判断是个全局问题——某一步棋是否真的把两个边界连起来了,得做整盘棋的并查集或 Flood Fill 判断。更麻烦的是,Hex 的「好棋」非常隐蔽,局部得失很难立刻反映到胜率上。你在右下角多占一个点,可能要到二十步之后才看出它卡住了对方的连通路径。

这种特性直接否掉了一个常见方案:用 Alpha-Beta 剪枝加启发式评估函数。在 Hex 上,评估函数很难写准,因为「当前谁更接近连通」不是一个能靠子力差算出来的指标。我试过用最短路径长度差做评估函数,跑出来的棋型特别「近视」,AI 只顾着堵眼前的路,完全不管远处的桥接和虚连接。这不是调参能救的,是评估函数先天缺了「大局观」。

于是选型就明确了:MCTS 负责搜索,深度学习负责给搜索提供大局观。MCTS 的随机模拟阶段不再用纯随机走子,而是让策略网络给出「哪些点值得落子」的分布,让价值网络给出「当前局面黑棋胜率大概多少」。搜索树只管往前推,评估交给网络,两者各干各擅长的部分。

2.2 MCTS 四阶段:选择、扩展、模拟、回传的落地细节

MCTS 的标准四阶段在 Hex 上有一个必须注意的点:模拟阶段到底模拟多少步。纯随机模拟可以一路下到终局,但 Hex 一盘棋的步数和棋盘格点数相当,11 路最多 121 步,随到下完成本不低。加上策略网络后就不一样了——我用的方案是模拟阶段只走 30 到 40 步就停,用价值网络直接打分,不再下到终局。

选择阶段用的是 UCT 公式:

def select(node): best_child = None best_value = -float('inf') for child in node.children: exploitation = child.wins / max(child.visits, 1) exploration = node.c_puct * child.prior * math.sqrt(node.visits) / (1 + child.visits) uct_score = exploitation + exploration if uct_score > best_value: best_value = uct_score best_child = child return best_child

这里的child.prior是策略网络给出的先验概率,不是随机初始化的小数。c_puct是探索系数,我在 11 路上取 1.5,在 13 路上取 2.0。为什么不一样?因为棋盘越大,搜索宽度越宽,如果探索系数太小,MCTS 会过早钻进策略网络最看好的那条线,忽略其他可能的好棋。注意看exploration的分母是1 + child.visits而不是child.visits,这是为了防止某个子节点访问次数为 0 时除零,同时避免刚扩展的节点被无限偏好。

扩展阶段不是每个节点都扩展。我设置了一个阈值:只有节点访问次数达到 20 次才扩展子节点。这样能省不少内存,因为 Hex 的合法落子点太多,全展开的话节点数会爆炸。20 这个数字不是玄学,是我对比过 10、20、40 三档后选出来的——太小会让搜索树太浅,太大则搜索广度不足。

回传阶段有一个 Hex 特有问题:胜率归属要看当前节点是谁的先手。Hex 没有平局,所以回传只能回传 1 或 0,但要注意 MCTS 节点里存的是「相对当前落子方」的胜率还是「绝对黑方」的胜率。我统一用「黑方胜率」存储,回传时根据轮到谁走决定加 1 还是加 0。这个细节做错了,训练出来的价值网络会学出一个永远在 50% 附近震荡的废物模型。

def backpropagate(node, result): while node is not None: node.visits += 1 if node.current_player == BLACK: node.wins += result else: node.wins += (1 - result) node = node.parent

这里result是黑方视角的最终胜果,黑胜为 1,白胜为 0。节点轮到自己走时,自己的胜果就是result;轮到对手走时,对手赢就意味着自己输。这段逻辑在训练时经常被写反,写反后 AI 会「越学越菜」,而且很难排查。

2.3 策略网络与价值网络:为什么合在一个模型里

这个项目的模型结构是一个双头网络,共享卷积层特征提取,分两个输出头:一个输出动作概率分布,一个输出局面价值。有人问过能不能拆成两个独立网络,我的回答是:能,但不是好选择。共享特征提取层最大好处是两个任务互相正则化——策略任务逼着网络关注「哪里能落子」,价值任务逼着网络关注「整体谁优」,两者共用底层的棋盘特征表示,训练数据量相同时学出来的特征更鲁棒。

网络输入我用的是一张双通道图:通道 0 放黑子位置,通道 1 放白子位置,空位两个通道都是 0。这里有个容易踩的坑:有些实现会把「轮到谁走」也编码成通道。我试过加一个「当前玩家」通道,结果训练反而变慢,因为价值网络已经能从输入推断是谁的回合——数一数棋盘上黑子白子数量就能算出来,加显式通道纯粹是给网络塞冗余信息。

class HexNet(nn.Module): def __init__(self, board_size, num_filters=64): super().__init__() self.conv1 = nn.Conv2d(2, num_filters, kernel_size=3, padding=1) self.conv2 = nn.Conv2d(num_filters, num_filters, kernel_size=3, padding=1) self.policy_head = nn.Conv2d(num_filters, 1, kernel_size=1) self.value_head = nn.Conv2d(num_filters, 1, kernel_size=1) self.fc_value = nn.Linear(board_size * board_size, 128) self.fc_out = nn.Linear(128, 1) def forward(self, x): x = F.relu(self.conv1(x)) x = F.relu(self.conv2(x)) policy = self.policy_head(x).view(x.size(0), -1) policy = F.log_softmax(policy, dim=1) value = self.value_head(x).view(x.size(0), -1) value = F.relu(self.fc_value(value)) value = torch.tanh(self.fc_out(value)) return policy, value

注意策略头输出的是log_softmax概率,价值头输出用tanh压到 -1 到 1 区间,表示黑方视角的胜率映射。为什么用log_softmax而不是softmax?因为后面算交叉熵损失时用NLLLoss更稳定,数值上不会因为概率趋近 0 或 1 而溢出。价值头的tanh是很讲究的:如果不压缩,网络输出可能在 0 到 1 之外,导致 MCTS 回传的胜率有时候大于 1 有时候小于 0,整个搜索树的数值逻辑直接崩。

训练数据来自自对弈:当前模型自己和自己下棋,每步棋记录局面、策略网络给出的概率分布、最终胜者。价值网络的标签就是最终胜者视角的 +1 / -1。这里有个细节,策略网络的标签不是「最终胜者的落子」,而是 MCTS 搜索后访问次数归一化的分布。也就是 MCTS 替网络「想得更远」,网络的策略头学着去逼近 MCTS 的搜索结论。这也是 AlphaGo 系方法的核心:老师是搜索,学生是网络,学生学完了再回去当老师。

3. 核心实现:从棋盘判断到自对弈训练管线

3.1 棋盘表示与连通性判断的正确写法

Hex 棋盘用一维数组存最简单,一个长度为board_size * board_size的整数数组,0 表示空,1 表示黑,2 表示白。落子合法性检查就是看该位置是否为 0。真正容易写错的是连通性判断——判断黑棋是否已经连通左上和右下边界。

我用的是并查集(Union-Find),把棋盘上的每个格点当做一个集合成员,同时把四个边界虚拟成两个超级节点。黑棋的左上边界和右下边界各自设一个虚拟节点,所有与左上边界相邻的黑色棋子都和虚拟节点 0 合并,所有与右下边界相邻的黑色棋子都和虚拟节点 1 合并。每次落子后,把新棋子和它的六个邻居合并,然后查虚拟节点 0 和 1 是否在同一个集合里。

class HexBoard: def __init__(self, size): self.size = size self.board = [0] * (size * size) self.uf = UnionFind(size * size + 2) self.top = size * size self.bottom = size * size + 1 def neighbors(self, pos): row, col = divmod(pos, self.size) offsets = [(-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0)] for dr, dc in offsets: nr, nc = row + dr, col + dc if 0 <= nr < self.size and 0 <= nc < self.size: yield nr * self.size + nc def place(self, pos, player): if self.board[pos] != 0: return False self.board[pos] = player if pos // self.size == 0 and player == BLACK: self.uf.union(pos, self.top) if pos // self.size == self.size - 1 and player == BLACK: self.uf.union(pos, self.bottom) for nb in self.neighbors(pos): if self.board[nb] == player: self.uf.union(pos, nb) return self.uf.find(self.top) == self.uf.find(self.bottom)

这个实现的性能关键在neighbors函数不要每次落子都重新算偏移量。我见过一版实现把offsets放在函数内部每次重新创建,跑 100 局自对弈慢了将近 30%。把offsets提为类常量后明显改善。另外注意边界虚拟节点的编号必须从size * size开始,避免和真实格点编号冲突。

3.2 MCTS 节点与搜索树的缓存机制

MCTS 节点我建议做成轻量级对象,不要为了省事把所有属性塞进字典。每个节点至少要有:parent、children、visits、wins、prior。prior是策略网络对这个节点对应落子点的先验概率,在节点被扩展时一次性存入。搜索树在每局游戏内复用,但局与局之间要清空,否则上一局的信息会污染下一局的先验。

有一个容易被忽略的优化:MCTS 搜索过程中对同一个节点反复调用策略网络是不划算的。我在实现里加了一个「叶子节点缓存」,当一个节点被选为叶子并扩展后,立刻把策略网络输出的概率分布存进prior,之后所有经过这个节点的搜索路径都直接用缓存的先验,不再重新推理。这一步能把单局训练时间降 40% 左右。

class MCTSNode: __slots__ = ('parent', 'children', 'visits', 'wins', 'prior', 'player') def __init__(self, parent, prior, player): self.parent = parent self.children = {} self.visits = 0 self.wins = 0.0 self.prior = prior self.player = player

为什么用__slots__?因为 MCTS 节点数量动不动几十万个,每个节点省下__dict__字典的内存开销,整体内存占用能降一半。训练 13 路棋盘时,这个优化直接把峰值内存从 2.8GB 降到了 1.5GB 左右。这在小显存机器上是很关键的一笔账。

搜索树复用的规则是:AI 落完一步棋后,把根节点切换到「对手那一步棋对应的子节点」,然后丢掉根节点以下的其他兄弟子树。这个切换能保留之前搜索过的大部分路径,让下一轮 MCTS 从已有记忆里继续深挖。但要注意:切换后,新根节点的parent要置为None,否则回传阶段会一路回溯到被丢弃的旧根节点,把垃圾信号写进新树。

3.3 自对弈数据生成管线的参数细节

自对弈是这套 AI 的训练数据来源。我用的流程是:每轮生成 100 局自对弈,每局结束后取出所有(局面, MCTS策略分布, 胜者)三元组存盘。算力紧张时可以只存每局最后 30 步的数据,前几十步都是布局阶段,MCTS 的结论和信息量都不如中后盘直接。

温度参数是控制搜索分布「锐度」的关键。开局阶段温度设为 1.0,让分布尽量平滑,鼓励探索不同的布局;进入中盘后逐步降为 0.5;最后 10 步直接设为 0.1,相当于几乎总是取访问次数最多的落子点。这个设计模仿了人类棋手的节奏:开局广撒网,终盘下最稳的一手。

def generate_game(model, board_size, temperature_schedule): board = HexBoard(board_size) game_data = [] mcts = MCTS(model) steps = 0 while not board.game_over(): temp = temperature_schedule(steps) root = mcts.search(board, num_simulations=400) visit_counts = [child.visits for child in root.children.values()] probs = softmax([math.log(v) / temp if v > 0 else float('-inf') for v in visit_counts]) move = sample_from_probs(board, root.children, probs) game_data.append(encode_board(board), probs, board.current_player) board.place(move, board.current_player) mcts.switch_root(move) steps += 1 winner = board.get_winner() labeled_data = [(board_state, probs, 1 if winner == player else -1) for (board_state, probs, player) in game_data] return labeled_data

num_simulations=400是 11 路上的设定,13 路我建议提到 800。不要擅自把模拟数降到 200 以下——我试过一次,生成的数据棋力明显变弱,模型学完后期期艾艾,怎么调学习率都救不回来。访问次数的log转化也很讲究:直接用访问次数做 softmax,强节点的概率会被放得太大,log之后分布更接近 MCTS 的真实倾向。

3.4 训练循环:损失函数组合与学习率设定

训练环节相对简单,但有个大坑:策略损失和价值损失的权重配比。我最终用的是 1:1,但一开始试过 2:1(策略损失权重更大),结果模型策略头学得很好,价值头却输出了一个永远在 0 附近的值。价值头废掉后,MCTS 基本靠策略头单腿走路,动作还行但缺少长远判断。检查后发现:价值损失本来收敛就比策略损失慢,如果权重再小,梯度几乎全被策略头霸占,价值头根本学不动。

optimizer = torch.optim.Adam(model.parameters(), lr=0.001, weight_decay=0.0001) for epoch in range(epochs): for batch in data_loader: board_input, policy_target, value_target = batch policy_logits, value_out = model(board_input) policy_loss = F.nll_loss(policy_logits, policy_target) value_loss = F.mse_loss(value_out.squeeze(), value_target) total_loss = policy_loss + value_loss optimizer.zero_grad() total_loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step()

学习率 0.001 搭配weight_decay=0.0001是稳定的组合,不要一上来就用 0.01。我踩过一次:0.01 学习率跑 200 个 batch 后 loss 直接飞了,回不来的那种。梯度裁剪的5.0阈值防止某个极端 batch 把参数冲走,尤其在自对弈早期,MCTS 分布还很混乱时,偶尔会出现一个巨大的策略损失。有了裁剪,训练曲线稳定很多。

训练集不要反复用同一批自对弈数据。每次生成新数据后,我会保留最近 5 次生成的数据混合训练,更早的丢弃。理由是:模型在变强,老数据里的棋型和当前模型水平不匹配,会让训练信号互相打架。

4. 避坑与常见问题:训练不收敛、评估翻车的五个原因

4.1 纯随机模拟的 MCTS 胜率上不去

现象:把策略网络去掉,只留价值网络指导搜索,发现 AI 的棋力在 11 路上也只有 50% 出头的胜率,比纯随机下略好一点点。

原因:Hex 的状态空间太大,纯随机模拟到达终局时经常把关键连通路径落到错误位置,模拟出来的胜负和真实棋局好坏关系很小。价值网络虽然能打分,但 MCTS 在模拟阶段没有具体走子方向,导致模拟分支太多,真正有价值的路径分不到足够搜索次数。

解决:MCTS 模拟阶段不再随机走棋,而是直接用策略网络输出做概率采样。具体做法是将模拟循环里的随机落子函数换成按策略分布采样的函数。我改完这个点后,胜率从 55% 直接跳到 78%。

4.2 价值网络震荡不收敛

现象:loss 曲线一直在 0.6 到 0.9 之间波动,十几轮训练后还在原地踏步,价值头输出始终贴着 0。

原因:两处——一是价值标签给错了,自对弈数据里胜者标注用了「黑方是否赢」而不是「当前落子方是否赢」,导致同样局面在不同对局中标签互相矛盾;二是学习率太高,价值头梯度本来就稀疏,学习率大一点就来回震荡。

解决:先把数据生成代码里的标签逻辑和backpropagate统一口径,全用「当前落子方视角」存储,训练时再换算成「黑方视角」。然后学习率降到 0.0005,同时把价值头的tanh输出前加一层LayerNorm,有效抑制了震荡。改完后 loss 在第 30 轮左右进入稳定下降通道。

4.3 训练数据雷同导致模型过拟合

现象:模型训练 loss 降得很低,但和 baseline 对战胜率反而比训练中期还差,明显出现过拟合迹象。

原因:自对弈时温度参数设置太激进。我把全局温度固定为 0.5,导致每个局面的落子分布都偏向少数几个强点,生成的数据多样性不足,模型反复看相近的棋型,学会了「背题」而不是「理解」。

解决:温度按步数衰减。前 15 步温度保持 1.0,让布局阶段产生更多探索;中盘指数衰减到 0.5;最后 10 步降到 0.1。同时每次落子前给根节点的先验概率注入 Dirichlet 噪声,我用alpha=0.3,保证即使同一局面重复出现,搜索路径也会有差异。

4.4 MCTS 切换根节点后搜索质量骤降

现象:每步棋之间 MCTS 搜索至少 400 次模拟,但落子后切换到新根节点,下一轮搜索访问次数涨得很慢,且经常选出的棋和上一轮预估的次优解一致。

原因:切换根节点时,没有清除新根节点的parent引用。回传阶段新节点的backpropagate会沿着parent链一路回溯到旧的根节点,把新的搜索统计量错误叠加到已经被丢掉的旧路径上,污染了整棵树的统计数据。

解决:switch_root方法里除了把根节点指到目标子节点,必须设置root.parent = None,同时清空根节点周围无用的兄弟子树引用。加这一个赋值语句后,搜索效率恢复,访问次数分布也正常了。

4.5 训练和推理时的棋盘输入不一致

现象:模型训练时胜率挺好看,但部署到实际对战时棋力断崖式下跌,甚至出现走子前判断胜率和训练时差 30% 的诡异情况。

原因:训练代码里对棋盘做的预处理(比如把黑棋表示为 1、白棋表示为 2、空为 0)和推理代码里不统一。我遇到过最隐蔽的一次是训练时把棋盘按「黑先手」角度编码,推理时却直接用了「当前先手」角度编码,通道顺序完全颠倒。

解决:把棋盘编码函数抽成一个公共工具函数,训练脚本和推理脚本都调用同一个函数。我建议所有局面统一编码为「黑方视角」,推理时如果是白方回合,先把棋盘做 180 度旋转再输入网络,预测结果再旋回来。从那以后,训练和推理的棋力就对齐了,再没出现「训练一条龙,对战一条虫」的情况。

5. 进阶用法:用对称性、动态参数与模型评估把 AI 再推一档

模型能稳定训练之后,大部分人以为就完了,其实还能再榨出不少棋力。我总结三个性价比极高的进阶手法:对称性增强、动态 CPuct、模型评估协议。

对称性增强是最容易做收益又最稳定的一项。Hex 棋盘具有六重对称性(旋转 60 度的倍数加镜像翻转),自对弈生成数据时,把每一条训练样本做六次变换后同时存盘,等效于把训练数据量乘以 6。具体做法是:拿到一个局面后,生成六个等价的棋盘编码和对应的策略目标,价值标签不变。因为策略输出也是棋盘位置上的概率分布,所以变换时要同步调整策略目标的落子坐标。我实现了一个transform_board_and_policy函数,用二维坐标映射表一次性算出变换后的棋盘和策略向量。这个技巧把训练所需的牌局数从 200 局降到了 35 局左右,模型棋力却没有明显下降。

动态 CPuct 是搜索参数的进阶调优。前面提到的c_puct=1.5是固定值,实际更好的做法是让探索系数随模拟次数变化:搜索开始时比较大,鼓励先看广;搜索进行到 200 次以后逐步缩小,让树专注于深挖高胜率路径。我用的公式是c_puct = 1.2 + 0.8 * exp(-visits / 1000),初始约 2.0,之后指数衰减到约 1.2。替换掉固定值后,13 路棋盘上的胜率提升了约 4%。

模型评估协议是决定你能不能自信上线模型的标尺。我习惯的方式是:每训练 50 轮,让当前模型和上一个 checkpoint 打 20 局,胜率超过 55% 才保留新模型;同时和纯 MCTS baseline 打 40 局,记录胜率曲线。注意对战时要关闭训练模式的随机性,包括 Dirichlet 噪声和温度,全部用贪心策略。没有这套协议,你很难判断某个改动到底是有提升还是纯噪声。

最终让这套 AI 稳定跑起来的,就是不断重复「自对弈 → 训练 → 评估 → 替换」的循环。作为一个不太喜欢写训练脚本的人,我以前总是跳过评估直接替换模型,结果经常把版本回退,浪费不少时间。从那以后我每次改完网络结构或数据生成逻辑,都强制走一遍评估协议,先打够 20 局再谈替换。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询