简介:这是一份基于Python深度神经网络与蒙特卡洛树搜索(MCTS)的五子棋博弈引擎完整项目,面向人工智能及计算机相关专业学生的课程设计、毕业设计与算法进阶。资源包含AlphaZero系列训练代码、策略价值网络、纯MCTS对战、人机对弈与自对弈脚本等15个Python源文件,另有13个pyc编译文件、4个训练好的神经网络模型、2个对弈过程动图、3个Markdown文档,并附带Flask与Django两套系统部署文档,共39个文件,整体包体约2.49MB,目录结构清晰,便于按模块阅读和二次开发。项目已通过导师指导认可,答辩评分达95分,代码经测试运行成功,可直接用于毕业设计、课程设计或课堂演示,也可在此基础上扩展实现更丰富的博弈规则与交互功能。已有134人学习下载,适合希望深入理解深度强化学习、蒙特卡洛树搜索在棋类博弈中实际落地的读者。
1. 用Python深度神经网络做五子棋博弈引擎:先想清楚这三层
五子棋的15×15棋盘一共有225个落点,单靠状态穷举不现实,但也没复杂到必须照搬完整AlphaGo训练管线。基于Python深度神经网络的五子棋博弈引擎,核心其实只有三件事:把一盘棋转成张量,用卷积网络输出落子概率和局面胜率,再让蒙特卡洛树搜索以这两组数据做决策。很多人用Python跑通训练后,得到一个能输出概率的网络,就以为它“会下棋”——真正对弈时会发现,它要么盲目落子,要么反复打同一个地方,原因就是把网络和搜索彻底割裂了。
这套引擎需要解决的,正是从棋盘表示、策略价值网络、自对弈数据生成到树搜索的完整闭环,最后还要把模型包装成能被客户端调用的服务。五个环节里,任何一个做糙了,最终对弈水平都会断崖式下跌。这篇文章面向用Python做棋牌AI或策略游戏AI的后端工程师,默认你熟悉tensor维度和基本的conv2d,但不要求你有完整的强化学习基础。我会把网络尺寸、训练流程、部署时最容易踩的坑一次说清。
开局先给结论:一个能用的五子棋深度神经网络引擎,代码量并不大,麻烦都在接口设计和训练稳定上。如果你的目标是线上对弈体验而不是发论文,就按“小网络、大搜索、重掩码、稳部署”的路线走,这是目前落地性价比最高的方案。
2. 五子棋博弈引擎的棋盘编码与策略价值双头网络设计
2.1 15×15棋盘的三通道张量与落子表示
深度神经网络不认坐标,只认张量。五子棋棋盘最直接的做法是用一个二维矩阵表示,黑子为1、白子为-1、空位为0。但单一矩阵喂进卷积网络有个问题:神经网络无法从单个平面上区分“当前该谁走”。黑白互换后,同样的形状可能是胜势也可能是败势。
常见做法是把棋盘压成多通道张量。我一般用三通道,形状为(3, 15, 15):
- 第0通道:当前方棋子。轮到黑方时,黑子为1,其余为0。
- 第1通道:对手方棋子。轮到黑方时,白子为1。
- 第2通道:常数通道,整层为1,表示“轮到我落子”,相当于给网络一个全局先验。
为什么不用更复杂的历史走子叠加?对于五子棋这种局部特征主导的棋类,残差层能从当前盘面推出多数战术信息,历史通道收益不大,反而让输入张量变大、对局回放时的编码逻辑变重。如果你后面想换用6到8个历史通道去处理禁手规则或长连识别,结构上也不用改,只在编码函数里拼一个HistoryStack即可。
落子策略输出需要展平成一维向量。225个候选点,网络输出的logits长度就是225,经过softmax后得到每个点的落子概率。注意,这里的概率是“未被掩码的合法落子的概率”,掩码的逻辑放在网络输出之后,而不是训练数据里先删掉非法点。
2.2 用PyTorch搭建五子棋深度神经网络的卷积结构
策略价值双头网络是这类引擎的标配。共享卷积主干提取局面特征,策略头输出225维落子logits,价值头输出一个标量胜率。我用PyTorch实现的一个最小可跑结构如下:
import torch import torch.nn as nn class GomokuNet(nn.Module): def __init__(self, board_size=15, planes=32): super().__init__() self.B = board_size # 主干:3通道输入,计划到32通道 self.conv_first = nn.Conv2d(3, planes, kernel_size=3, padding=1) self.bn_first = nn.BatchNorm2d(planes) # 一个残差块:两层3x3卷积,输入输出形状不变 self.res_conv1 = nn.Conv2d(planes, planes, kernel_size=3, padding=1) self.res_bn1 = nn.BatchNorm2d(planes) self.res_conv2 = nn.Conv2d(planes, planes, kernel_size=3, padding=1) self.res_bn2 = nn.BatchNorm2d(planes) # 策略头:1x1卷积先降维,再展平到候选点 self.policy_conv = nn.Conv2d(planes, 8, kernel_size=1) self.policy_bn = nn.BatchNorm2d(8) self.policy_fc = nn.Linear(8 * board_size * board_size, board_size * board_size) # 价值头:同样先降维,再压到1个标量,tanh限制在-1到1 self.value_conv = nn.Conv2d(planes, 4, kernel_size=1) self.value_bn = nn.BatchNorm2d(4) self.value_fc1 = nn.Linear(4 * board_size * board_size, 64) self.value_fc2 = nn.Linear(64, 1) def forward(self, x): x = torch.relu(self.bn_first(self.conv_first(x))) # 残差连接:输入和输出形状一致才能相加 identity = x x = torch.relu(self.res_bn1(self.res_conv1(x))) x = self.res_bn2(self.res_conv2(x)) x = torch.relu(x + identity) # 双头输出 p = torch.relu(self.policy_bn(self.policy_conv(x))) p = self.policy_fc(p.flatten(1)) v = torch.relu(self.value_bn(self.value_conv(x))) v = self.value_fc2(torch.relu(self.value_fc1(v.flatten(1)))) v = torch.tanh(v) return p, v代码逻辑不复杂,重点在三个参数决策上。planes取32,对五子棋已经完全够用,64会让训练变慢且收益很小。kernel_size取3,因为五子棋的连五特征基本都在3×3或4×4窗口内。残差块取1个即可,堆到3个以上在小数据场景下容易过拟合。
策略头最后一层是线性层,输出的是logits,不是概率。softmax放在外面,因为掩码操作要在softmax之前把非法点的logits替换成负无穷,否则softmax会把概率分摊给不可落子的位置。
2.3 蒙特卡洛树搜索接管神经网络输出的理由
神经网络单独推理时,策略头给出的分布是“全局静态评估”,它没有显式推算后续几步。直接取argmax会反复下同一个看起来很合理但实际被对手反制的点。蒙特卡洛树搜索MCTS在这里承担了“动态推演”的角色:
每个模拟从根节点出发,按节点存储的Q值和先验概率P(u, s)逐步下探,公式如下:
a = argmax_a( Q(s,a) + c_puct * P(s,a) * sqrt(sum_N(s)) / (1 + N(s,a)) )Q值来自该节点子树的价值回传,P来自网络策略头,N是访问次数。搜索结束后,按根节点访问次数N而不是Q值来选择落子。这样网络专注于提高先验概率的命中率,而搜索负责把“局部最优”放大成“全局可信”。
落地时MCTS每次模拟最多走几十步,不会模拟到终局。价值头的收益在这里体现出来——它不需要走完一整盘棋,就能给到叶子节点一个近似胜负评估,模拟次数控制在100到400即够。
2.4 网络损失与数据样本形式
训练数据是自对弈过程中产生的三元组(state, pi, winner)。state是上面定义的三通道张量。pi是树搜索给出的落子概率分布,维度225。winner是这盘棋结束时当前视角下的胜负,黑棋视角为1.0,白棋视角为-1.0,和棋为0。
损失函数分两块:
policy_loss = -torch.mean(torch.sum(pi_target * torch.log_softmax(p_logits, dim=1), dim=1)) value_loss = torch.mean((value_out - value_target) ** 2) loss = policy_loss + 1.0 * value_loss + 1e-4 * weight_decaypolicy_loss用的是交叉熵,相当于把MCTS搜索出来的好落子分布当作软标签。value_loss用MSE拉近网络对局面的估值。这里有个很容易犯的错:不能只在数据生成时用当前玩家视角存winner,必须在存储样本时记录“当前轮到谁”,否则黑白互换后价值头的target会反号。
3. Python五子棋深度神经网络的自对弈训练循环
3.1 一局自对弈的生成流程
训练数据不能只用人类棋谱,否则网络永远学不到新手玩家没走过的变化。自对弈的流程是:当前网络自己和自己下,每步调用MCTS得到分布pi,把(当前棋盘张量、pi、当前执子方)存下来,直到分出胜负,再把最终胜负回填给这一局所有样本。
核心循环长这样:
def self_play_one_game(model, mcts, board_size=15): board = Board(board_size) samples = [] while not board.is_game_over(): state = board.encode_current_player() # (3, 15, 15) pi = mcts.search(board, model) # 返回225维概率分布 move = sample_from_distribution(pi, temperature=1.0) samples.append({ "state": state, "pi": pi, "player": board.current_player }) board.play(move) winner = board.get_winner() # 1为黑胜,-1为白胜,0为和棋 for s in samples: s["value"] = winner if s["player"] == 1 else -winner return samples这段流程里有三个关键点。第一,board.encode_current_player()输出的state必须始终从当前玩家的视角构建,第0通道放自己棋子,第1通道放对手棋子,这样网络看到的数据分布是自洽的。第二,temperature在训练初期取1.0让落子更有探索性,训练后期或正式对弈时降为0.1左右,偏向确定性。第三,winner回填时要乘上player标识,否则白棋样本的价值目标全部反号,价值头会训成随机猜测。
MCTS搜索函数里,模拟次数要尽量维持稳定。一局棋大约需要搜索60到120步,每步模拟200次,也就是一万次以上的叶子节点评估。如果机器配置一般,建议先把planes调小到16,模拟次数调到100,跑通一局再逐步增大。
3.2 训练数据的批量加载与去相关
自对弈生成的样本有个典型问题——相邻棋局高度相关。如果直接按顺序切batch,模型会在同一批里看到大量相似局面,梯度更新方向被某几局棋主导,loss曲线来回震荡。
建议每轮训练先把样本池里的棋局完全洗牌,再随机取样。我在项目里会把三到五局自对弈的样本合并成一个buffer,采样时用random.shuffle打乱棋局顺序,再从每局里随机抽连续片段。这样每批次都能覆盖到不同阶段的中盘和残局。
采样后,单个输入要转换成tensor并且归一化。这里注意一个问题:不要对(3, 15, 15)的0/1张量做mean/std归一化。通道值本身就是离散标志位,归一化反而抹掉了“这层全1表示轮次”的信息。直接以tensor传入即可。
3.3 超参数表与训练循环
自对弈引擎的超参数分散在数据生成和网络训练两端,我习惯把它们统一放到config.py里,避免部署时再改代码:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| board_size | 15 | 标准五子棋棋盘 |
| planes | 32 | 卷积通道数,CPU环境降到16 |
| residual_blocks | 1 | 残差块数量,1到2即可 |
| n_sim | 200 | MCTS每次落子的模拟次数 |
| temperature | 1.0 | 数据生成时的探索温度 |
| batch_size | 256 | 每个训练批次的样本数 |
| learning_rate | 1e-3 | Adam优化器初始学习率 |
| weight_decay | 1e-4 | L2正则系数 |
| buffer_games | 5 | 样本buffer中保留的棋局数 |
| epochs_per_iteration | 10 | 每轮自对弈后的训练轮数 |
训练主循环看起来简单,但每次迭代之间要重新自对弈。完整流程是:
for iteration in range(200): game_data = [] for _ in range(2): # 每轮生成两局 game_data.extend(self_play_one_game(model, mcts)) random.shuffle(game_data) dataset = TensorDataset( torch.tensor(np.array([d["state"] for d in game_data])), torch.tensor(np.array([d["pi"] for d in game_data])), torch.tensor(np.array([d["value"] for d in game_data])) ) loader = DataLoader(dataset, batch_size=256, shuffle=True) model.train() for _ in range(10): for states, pis, values in loader: p_logits, v = model(states) loss = compute_loss(p_logits, v, pis, values) optimizer.zero_grad() loss.backward() optimizer.step()训练时比较容易忽略的是数据格式。states必须是float32,pi是float32,value是float32。如果state用int类型直接构造tensor,第一个卷积层就会报数据类型不匹配。这个报错在PyTorch里很常见,但新手往往会去改网络结构,实际上加一句.float()就能解决。
3.4 最优落子选择与推理逻辑
正式对弈时,不需要再走完整训练数据生成流程。拿当前棋盘状态编码后,输入模型得到p和v,再走一次MCTS返回根节点访问计数。落子选择按访问次数N而不是Q值。
推理阶段的代码:
model.eval() with torch.no_grad(): state_tensor = torch.tensor(state, dtype=torch.float32).unsqueeze(0) logits, value = model(state_tensor) # 先掩码非法点,再softmax masked_logits = logits[0].clone() masked_logits[invalid_moves] = -float("inf") prob = torch.softmax(masked_logits, dim=0).view(15, 15)其中invalid_moves是布尔数组,已经落子的位置和禁手位置都会置True。注意这里不能直接在原logits上修改,因为后续可能还要保留原始logits做调试。把概率矩阵view成(15, 15)后,可以直接叠加到棋盘UI的热力图上,这一步对排查模型行为很有效。
4. 五子棋引擎的部署文档落地:模型导出、FastAPI服务与参数配置
4.1 模型导出与加载时的状态管理
训练完成后,需要把模型权重、配置参数和版本号一起固化。PyTorch最稳妥的保存方式是只存state_dict,不存整个模型对象。原因是模型类定义后续可能升级,直接torch.save(model)会把类的内部结构也存进去,换个环境加载就容易出现类名或属性路径不匹配。
导出代码很简单:
checkpoint = { "model_state": model.state_dict(), "board_size": 15, "planes": 32, "version": "gomoku-dnn-v1.0", } torch.save(checkpoint, "gomoku_engine.pt")加载时先按配置重建模型结构,再load_state_dict。这里有个部署高频坑:如果在训练时模型用了GPU,state_dict里的参数能正常转到CPU,但不要忘记在加载后调用model.eval()。Dropout和BatchNorm在训练与推理下的行为完全不同,五子棋网络里BatchNorm一旦没切eval,推理概率会严重漂移。
4.2 用FastAPI封装成独立引擎服务
直接对局时,客户端不可能加载PyTorch模型来做前向推理,尤其是前端页面场景。常见做法是把训练好的模型封装成一个HTTP服务,对局逻辑只通过接口交互。我常用FastAPI,原因就两点:自带/redoc接口文档方便联调,uvicorn重启时支持热加载。
引擎服务端最小实现:
from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() model, config = load_engine("gomoku_engine.pt") mcts = MCTS(config) class MoveRequest(BaseModel): board: list # 15x15,0空位,1黑子,2白子 current_player: int # 1或2,当前落子方 class MoveResponse(BaseModel): x: int y: int probability: float @app.post("/move", response_model=MoveResponse) def make_move(req: MoveRequest): state = encode_board(req.board, req.current_player) pi = mcts.search_with_board(state, model) x, y = choose_from_pi(pi) return MoveResponse(x=x, y=y, probability=float(pi[y * 15 + x]))board用15×15的二维数组直接传,而不是把225个点展开成一维,主要是方便前端渲染和人工排查。接口返回坐标和该点的搜索概率,前端拿坐标落子。如果客户端需要拿整盘棋做复盘,可以再加一个/game接口返回全部历史概率分布,但不要默认所有端都传大数据。
FastAPI服务启动时需要按配置文件加载模型。这个加载动作一定要放在模块顶层,也就是进程启动时只做一次。很多人把model加载写进/move函数里,导致每个请求都重新读一次权重文件,延迟和内存占用直接翻几倍。
4.3 部署配置参数与启动命令
部署时的参数需要和训练时的对局参数区分开。推理服务里的MCTS模拟次数、温度、超时时间都写在独立的部署配置文件中,而不是复用训练config。
| 部署参数 | 推荐值 | 说明 |
|---|---|---|
| n_sim | 100 | 线上实时对弈的模拟次数 |
| temperature | 0.05 | 线上落子近似贪心 |
| max_search_time | 1.0 | 单次搜索最多耗时(秒) |
| device | cpu | 无GPU时强制cpu |
| response_timeout | 3.0 | 客户端整体超时 |
| enable_log | true | 记录请求棋盘和响应坐标 |
启动服务的环境准备命令如下:
python -m venv .venv source .venv/bin/activate pip install torch fastapi uvicorn pydantic uvicorn engine_server:app --host 0.0.0.0 --port 8000 --workers 1这里注意,uvicorn的workers必须设为1。因为每个worker都会加载一份模型,多个worker之间内存浪费严重,而且MCTS的状态不是线程安全的。流量大时,横向多开服务实例再加负载均衡,比加worker更可控。如果服务器内存只有4G,建议pip安装CPU版PyTorch,模型推理速度在单次落子100模拟下也能达到200毫秒左右。
如果访问不稳,先检查三个地方:第一,torch是否装上了CPU版本而非空的依赖;第二,调用接口的content-type是否为application/json;第三,board数组形状是否严格为15×15。我做部署排障时,一半以上问题都出在客户端把matrix传成了一维list。
4.4 客户端调用与落子合法性校验
引擎服务端只做推理,不应承担全部棋规校验。但至少要校验当前请求里的落子位置没有越界,以及输入board的胜负状态。客户端拿到响应后,也要在本地再判断一次目标位置是否为空,避免服务端返回的坐标已被前端并发操作占用。
客户端调用代码示例:
import requests payload = { "board": board_to_list(board), "current_player": 1, } resp = requests.post("http://127.0.0.1:8000/move", json=payload, timeout=3) move = resp.json() if board[move["y"]][move["x"]] != 0: raise RuntimeError("engine returned an occupied position") board[move["y"]][move["x"]] = 1这里的board_to_list要做一次黑白转换,服务端统一约定传入的是原始棋盘,由服务端内部encode。把编码逻辑放在服务端而非客户端,能保证不同前端拿到的接口行为一致,也方便后期在服务端做禁手规则扩展。接口联调时,用日志把请求和响应各存一份,方便复现“客户端说下错位置”的争议。
部署文档里最容易被忽视的,是引擎版本与模型版本的对应关系。模型文件命名为gomoku_engine_20240601.pt,服务启动日志中打印模型加载的版本号。一旦客户端反馈走法异常,先看日志里的模型版本,再对比模型bin的md5,能省掉大量排查时间。
5. 深度神经网络五子棋引擎的三个验证技巧:回放热力图、负载评估与线上剪枝
五子棋引擎验收时,单纯跑几局人机对弈主观感太强。我习惯做三个可量化的验证,每个都对应一个具体环节。
首先是棋谱回放热力图。把训练数据或线上对局中每个落子点的概率用热力图叠加,能快速发现网络是否存在盲区。写一个很小的回放脚本:
def render_heatmap(prob_matrix, moves_history): import matplotlib.pyplot as plt plt.imshow(prob_matrix, cmap="hot", interpolation="nearest") for i, (x, y) in enumerate(moves_history): plt.text(y, x, str(i + 1), color="white", fontsize=8) plt.colorbar() plt.savefig(f"retro_{len(moves_history)}.png") plt.close()正常训练的网络热力图,落子位置周围的概率应该呈聚集状态。如果出现多个概率尖刺分散在棋盘四角,说明网络只记住了局部数量统计,还没形成结构化的棋理,需要继续增加自对弈棋局数量或调大MCTS模拟次数。
第二个技巧是负载评估。线上服务压测时,要关注的不只是接口响应时间,而是MCTS搜索时间随棋局进程的变化。中盘阶段候选点多,搜索树分支更密,单次响应可能比开局慢一倍。压测脚本按棋局阶段分段统计P99响应时延,如果发现残局反而变慢,多半是模拟器在判断终局时多做了几次全盘扫描,可以加一个快速终局short-circuit逻辑。
第三个也是最高频的优化点,搜索剪枝。五子棋不像围棋那样需要全局均匀思考,多数落子集中在已有棋子周围两格范围内。推理时可以先算一个候选区域,只有一定距离内的空位进入softmax掩码,这样能减少无效搜索分支。但要小心:开局前几步和对手突然在远端点棋时,必须保留全盘搜索或者至少保留最近一手周围的扩展窗口,否则会漏掉关键防守。
剪枝参数我放在部署配置里,默认生效距离为2,搜索时间紧张时调到1,开局检测到前三手时强制关闭剪枝。这样做通常能让单次搜索的模拟数提升30%以上,而胜率几乎没有损失。每次调整后,再用同一批历史棋谱做AB比对,记录胜率变化,确认无退化再上线。
本文还有配套的精品资源,点击获取