基于Python的黑白棋强化学习实战:从游戏逻辑到DQN训练全解析
简介一份基于Python的黑白棋游戏毕业设计资料包将强化学习引入经典棋类博弈面向计算机相关专业毕业生、强化学习入门者及游戏AI爱好者解决从零构建游戏环境并训练可自我进化的AI对手这一完整课题需求。资源共67个文件、64.72MB包含21个Python源文件、训练完成的H5模型权重、Pygame界面所需图片资源以及PDF、PPT、TeX等多格式讲解文档和训练脚本、说明文件等目录按游戏逻辑、界面展示、强化学习模块、训练评估分层组织便于按需查阅与二次开发。已有160人学习该资源。整套内容覆盖游戏界面、规则逻辑、Q-Learning、DQN、策略梯度等强化学习算法的实现并引入经验回放、探索与利用平衡等训练技巧支持人机对战和AI自对弈两种模式训练好的模型权重可直接加载体验对战效果既可充当毕业设计的完整方案也可作为强化学习算法的动手实践教程帮助读者从环境搭建、模型训练到性能评估走通全流程。1. 黑白棋遇上强化学习这个毕业设计选题的真正难点不在“游戏”黑白棋Othello是强化学习入门最友好的棋类项目之一规则简单到半小时能写完棋盘逻辑但局面评估又复杂到足够撑起一篇毕业设计的算法部分。基于 Python 的黑白棋游戏加上强化学习应用本质上是让你把“状态、动作、奖励”三件套在一套可运行的代码里完整走一遍从棋盘逻辑、GUI 界面写到 DQN 训练与对局评估。适合用它来做毕业设计的人是已经学过 Python 基础、想往深度强化学习算法方向深入却缺一个“能跑的闭环”的同学。我接这种项目踩过的最大一个坑是算法本身往往不是翻车点棋盘逻辑和奖励设计才是。这篇文章按我一贯做毕设项目的方式把游戏逻辑、强化学习训练、调参与避坑一次讲透让拿到这类项目压缩包的人能直接照着复现而不是只在答辩 PPT 上“跑通”。2. 为什么强化学习能下黑白棋状态、动作、奖励三件套先立住2.1 黑白棋规则里的“合法落子”就是动作空间黑白棋 8x8 棋盘黑先白后落子必须能夹住对方至少一个棋子否则只能跳过。终局时子多者胜。动作空间看起来是 64 个位置但大量位置在任意时刻都是非法的每一步可用动作从几个到几十个不等。对强化学习来说合法动作为零时要能“跳过一步”否则环境会直接卡死。这个逻辑很多第一次写的人会漏等训练到一半才发现 AI 在空棋盘上原地报错。常见做法是先把棋盘表示成 numpy 二维数组黑1白-1空0然后写合法性判定。用 numpy 不是因为它比 list 更“高级”而是后面状态编码要直接喂给神经网络numpy 转 tensor 只有一行的事而且八方向扫描时切片和赋值都更顺手。import numpy as np EMPTY, BLACK, WHITE 0, 1, -1 class Board: def __init__(self): self.grid np.zeros((8, 8), dtypenp.int8) self.grid[3, 3] WHITE self.grid[3, 4] BLACK self.grid[4, 3] BLACK self.grid[4, 4] WHITE def is_valid(self, r, c, player): 判断 (r, c) 是否为 player 的合法落子点 if self.grid[r, c] ! EMPTY: return False opp -player for dr in (-1, 0, 1): for dc in (-1, 0, 1): if dr 0 and dc 0: continue rr, cc r dr, c dc if not (0 rr 8 and 0 cc 8): continue if self.grid[rr, cc] ! opp: continue # 沿当前方向继续走直到遇到己方棋子或边界 while 0 rr 8 and 0 cc 8: rr dr cc dc if not (0 rr 8 and 0 cc 8): break if self.grid[rr, cc] EMPTY: break if self.grid[rr, cc] player: return True return False这个函数的核心是“方向扫描”从落子点出发沿八个方向逐个推进只有遇到对方棋子后另一端还有己方棋子这个方向才算有效。注意两个细节每次进入 while 循环前要先辘一步否则会把落子点本身当成起点边界检查必须在取 grid[rr, cc] 之前做numpy 越界不会温和地报错而是直接打断训练进程。返回值是布尔值后续生成合法掩码和翻转棋子都要复用它。有了合法性判断动作空间就明确了64 个位置里合法位为 1非法位为 0再加上一个“跳过”动作一共 65 个动作。这个掩码在训练时是必需品后面 4.2 节再展开。这里先记住结论黑白棋的动作空间不是 64而是 641。2.2 “子力差必胜”是错觉奖励函数比算法更值钱很多第一次做棋类强化学习的同学第一步就写“每步奖励 当前棋子数差”理由是“让 AI 多吃子总没错”。在黑白棋里这恰恰是典型陷阱。中盘子数领先不等于终局领先盲目多吃子经常把角落送出去——角落一旦被对方占住永远翻不回来直接决定后期局面。我一般这样设计奖励阶段奖励说明终局胜负1 / -1 / 0黑胜 1白胜 -1平局 0终局子数差4 到 64 归一化到 [-1, 1]可选用比胜平负更平滑中间步0 或子力差 × 0.01小系数辅助不能喧宾夺主可选塑形行动力差 × 0.01合法落子多的一方有棋活优势核心原则是终局胜负有最高权重中间步奖励只做“地形的缓坡”。如果中间奖励系数太大网络会学成“只贪当前子力”到了后期完全没有控角概念这在黑白棋里几乎等于不会下棋。另一个常见的中间奖励是行动力差——你可以落子的位置越多对方的可选位置越少说明你的棋“活”。行动力是黑白棋里比子力差更可靠的启发信号毕设里用它做塑形奖励答辩也有的讲。我踩过的坑是一开始只给终局奖励结果 3000 局训练完AI 对落子毫无偏好基本接近随机后来中间步加了一个小系数子力差才在 1000 局内看到“吃子”行为。但子力差系数不能提太高提到 0.05 之后模型又学会了不断往里填子送角落。最后我固定在 0.01效果最稳。2.3 毕设算法选型DQN、Double DQN 还是离线强化学习黑白棋这类回合制、状态离散、动作空间小的棋类最稳的起点是 DQN 或者 Double DQN。DQN 把局面当作图像输入输出每个动作的 Q 值配合经验回放和目标网络训练稳定性在棋类上表现不错。策略梯度类算法如 REINFORCE、PPO 也能做但方差大、需要更多样本毕业设计时间紧的话不建议上来就头铁。如果手里已经有一批高手对局数据比如从棋谱网站爬来的棋谱可以考虑离线强化学习方向比如 IQL。IQL 的好处是不需要在线与对手对弈直接用固定数据集学策略但落地时要处理行为策略和数据集覆盖问题——黑白棋棋谱不难找可你还需要做数据预处理、状态转码和每个动作的优势估计一个毕设的工程量会显著增加。我的建议是除非导师明确要求做离线强化学习否则优先做在线自对弈 DQN闭环短、效果可见、答辩时好演示。算法选型上还有一个折中方案用 Double DQN 替换标准 DQN改动只有一行——计算目标值时用当前网络选动作用目标网络取 Q 值能明显缓解 Q 值高估问题。标准 DQN 在黑白棋上经常出现“自己觉得稳赢实际在送子”的情况Double DQN 会好很多代码量增加不到五句。3. 先把棋盘逻辑和环境接口跑通这一步翻车后面全白搭3.1 棋盘数据结构一个 numpy 二维数组就够了棋盘逻辑不要搞复杂。我见过有人在毕设里用字典存棋子坐标、用列表套列表存棋盘、甚至用字节串编码整个局面完全没有必要。一个np.zeros((8, 8), dtypenp.int8)就能描述全部状态占用 64 字节内存小到可以忽略。def print_board(self): 终端打印棋盘方便 debug symbols {BLACK: ●, WHITE: ○, EMPTY: .} for r in range(8): row .join(symbols[self.grid[r, c]] for c in range(8)) print(f{r} {row}) print( 0 1 2 3 4 5 6 7)这段代码加在 Board 类里平时训练时不用 GUI 也能在终端盯盘。符号选用实心圆和空心圆比用 B/W 字母直观如果你在 Windows 终端下出现乱码把符号换成B和W就行。调试阶段我几乎每一步都会把棋盘打出来尤其怀疑翻转逻辑时终端打印比任何调试器都好使。3.2 合法落子与翻转判定八个方向的扫描边界翻转逻辑是所有棋盘逻辑里最容易写错的地方。错误典型有两种一是把八个方向的翻转混在一次循环里处理结果翻转数量不对二是先落子再判断合法性导致非法落子在棋盘上留下脏数据。正确顺序是先完整检查所有方向收集所有要翻转的坐标确认至少有一个方向成立再真正落子和翻转。def apply_move(self, r, c, player): 在 (r, c) 落下 player 的棋子并翻转非法则返回 False if not self.is_valid(r, c, player): return False opp -player flipped [] for dr in (-1, 0, 1): for dc in (-1, 0, 1): if dr 0 and dc 0: continue rr, cc r dr, c dc path [] while 0 rr 8 and 0 cc 8 and self.grid[rr, cc] opp: path.append((rr, cc)) rr dr cc dc if path and 0 rr 8 and 0 cc 8 and self.grid[rr, cc] player: flipped.extend(path) if not flipped: return False self.grid[r, c] player for rr, cc in flipped: self.grid[rr, cc] player return True这个函数我建议原样抄下来然后自己补几组测试用例。最经典的用例是黑棋在 (3, 4)、白棋在 (3, 5)黑棋在 (3, 3) 落子应该翻转 (3, 4) 一枚如果棋盘边界上连续一列对方棋子落子时最容易漏掉“末端出边界”的情况。把这段代码跑通后面强化学习环境才算有了地基。注意path和flipped的区别path只是当前方向上的候选翻转列表必须等确认末端是自己的棋子才合并进flipped不能一边扫描一边翻否则同一枚棋子会被前一个方向翻转后影响后续方向判断。这是新手最常踩的坑。3.3 把环境接口拆出来强化学习要的是 step 和 reset拿到压缩包后很多人第一反应是找游戏主界面文件但真正的核心是环境接口。强化学习不需要 GUI它需要的是一个能反复 reset、能接受动作、能返回状态和奖励的标准环境和 OpenAI Gym 的接口风格对齐后面接任何算法都顺手。所以我建议无论项目里有没有现成的自己都写一个ReversiEnv类。class ReversiEnv: def __init__(self): self.board Board() self.current_player BLACK self.pass_count 0 def reset(self): self.board Board() self.current_player BLACK self.pass_count 0 return self._state() def _state(self): return encode_state(self, self.current_player) def legal_mask(self, playerNone): player self.current_player if player is None else player mask np.zeros(65, dtypenp.float32) for r in range(8): for c in range(8): if self.board.is_valid(r, c, player): mask[r * 8 c] 1.0 mask[64] 1.0 # 跳过动作始终可用 return mask def step(self, action): if action 64: # 跳过 self.pass_count 1 else: r, c divmod(action, 8) if not self.board.apply_move(r, c, self.current_player): return self._state(), 0.0, False, self.legal_mask() self.pass_count 0 self.current_player -self.current_player done False reward 0.0 # 如果当前玩家无合法落子自动跳过 if self.legal_mask(self.current_player)[:64].sum() 0: self.pass_count 1 self.current_player -self.current_player if self.legal_mask(self.current_player)[:64].sum() 0: done True if self.pass_count 2 or not (self.board.grid ! 0).any(): done True if done: reward self._terminal_reward() return self._state(), reward, done, self.legal_mask() def _terminal_reward(self): black_count int((self.board.grid BLACK).sum()) white_count int((self.board.grid WHITE).sum()) diff black_count - white_count if diff 0: return 1.0 if self.current_player BLACK else -1.0 if diff 0: return -1.0 if self.current_player BLACK else 1.0 return 0.0这里的重点是pass_count机制当一方没有任何合法落子时自动跳过连续两方都无子可走才终局棋盘填满也会终局。很多人把“跳过”当成一个需要游戏界面点击处理的操作但在强化学习环境里它必须是一个标准动作并且要能在非玩家回合入场时自动触发。_terminal_reward是按当前视角返回胜负这样同一套函数黑棋白棋都能用不需要额外传参指定“谁赢了”。3.4 GUI 放在最后用 pygame 渲染棋盘和棋子GUI 是给人和 AI 对弈、以及答辩演示用的不是给训练用的。训练时如果用 pygame 实时渲染每一步速度会慢一个数量级而且 pygame 事件循环会把训练线程卡死。我一般在项目里划分成两个文件train.py只跑环境 算法play.py负责加载训练好的模型做可视化对弈。import pygame SIZE 64 GRID_COLOR (34, 139, 34) LINE_COLOR (0, 0, 0) def draw_board(screen, grid): for r in range(8): for c in range(8): x, y c * SIZE, r * SIZE pygame.draw.rect(screen, GRID_COLOR, (x, y, SIZE, SIZE)) pygame.draw.rect(screen, LINE_COLOR, (x, y, SIZE, SIZE), 1) if grid[r, c] BLACK: pygame.draw.circle(screen, (0, 0, 0), (x SIZE // 2, y SIZE // 2), SIZE // 2 - 4) elif grid[r, c] WHITE: pygame.draw.circle(screen, (255, 255, 255), (x SIZE // 2, y SIZE // 2), SIZE // 2 - 4)这段代码只有绘制逻辑没有事件循环。坐标计算用c * SIZE和r * SIZE注意 pygame 的坐标系是先 x 后 y也就是先列后行和 numpy 的grid[r, c]正好相反写循环时容易弄混。棋子半径我取SIZE // 2 - 4留出 4 像素边距画出来正好贴合格子不会挤到相邻棋子。4. 强化学习训练主循环状态编码、动作掩码与 DQN 更新4.1 状态编码三通道棋盘加上合法落子掩码神经网络不能直接吃grid里的-1、0、1因为在卷积层看来这只是一个单通道灰度图黑白两种棋子的语义区别会被数值大小扭曲。常见做法是拆成多通道己方棋子为 1、敌方棋子为 1、空位为 1每个通道都是 0/1 二值特征。这一步在强化学习项目里叫状态编码编码方式直接影响网络能不能学出来。def encode_state(env, player): 把棋盘编码成 3 通道特征shape(3, 8, 8) grid env.board.grid own (grid player).astype(np.float32) opp (grid -player).astype(np.float32) empty (grid EMPTY).astype(np.float32) return np.stack([own, opp, empty], axis0)player是当前行动方own和opp都是相对当前玩家而言的这样黑棋和白棋用同一个网络只是输入特征互换。你也可以再加一个通道表示合法落子位置但那个信息在动作选择阶段已经用到了估 Q 值时网络不一定需要另外看到。我试过四通道效果和三通道差不多三通道还省一点参数。编码完的 shape 是(3, 8, 8)PyTorch 默认的卷积输入也是(C, H, W)可以直接喂网络。4.2 DQN 网络结构与动作掩码黑白棋棋盘只有 8x8不需要太大网络。我用的是两层卷积加两层全连接参数少、训练快效果已经够用。结构上唯一特别的是输出维度为 6564 个落子位置加 1 个跳过动作。import torch import torch.nn as nn import torch.nn.functional as F class DQN(nn.Module): def __init__(self, in_channels3): super().__init__() self.conv nn.Sequential( nn.Conv2d(in_channels, 32, 3, padding1), nn.ReLU(), nn.Conv2d(32, 64, 3, padding1), nn.ReLU(), ) self.fc nn.Sequential( nn.Flatten(), nn.Linear(64 * 8 * 8, 256), nn.ReLU(), nn.Linear(256, 65), ) def forward(self, x): return self.fc(self.conv(x)) def select_action(model, state, legal_mask, epsilon, devicecpu): epsilon-greedy 合法动作掩码 if np.random.rand() epsilon: legal_indices np.where(legal_mask[:64] 0)[0] if len(legal_indices) 0: return 64 return int(np.random.choice(legal_indices)) state_t torch.tensor(state, dtypetorch.float32, devicedevice).unsqueeze(0) with torch.no_grad(): q model(state_t).squeeze(0) # shape (65,) q_masked q.clone() invalid torch.tensor(legal_mask[:64] 0.5, devicedevice) q_masked[:64] q_masked[:64].masked_fill(invalid, -1e8) return int(q_masked.argmax().item())动作掩码是这节的核心。对 DQN 来说不能直接在损失函数里处罚非法动作那样模型只能“学到别选这些位置”但真正高效的做法是让非法位置的 Q 值在argmax时不可能被选中。-1e8这个值不是随便选的它必须足够小小到即使非法位置的 Q 值被网络错误地学了很高也争不过任何合法位置。注意masked_fill只作用于前 64 个输出第 65 个跳过动作不遮罩因为跳过在任何时刻都是可以选择的合法动作。4.3 训练主循环经验回放、epsilon 衰减与目标网络训练循环是整篇代码里最长的一块但结构非常固定。我这里给出一个完整可跑的版本后面要改的只有超参数。import random from collections import deque def train_dqn(env, episodes3000, batch_size64, gamma0.99, lr1e-4): device torch.device(cuda if torch.cuda.is_available() else cpu) model DQN().to(device) target_model DQN().to(device) target_model.load_state_dict(model.state_dict()) buffer deque(maxlen20000) optimizer torch.optim.Adam(model.parameters(), lrlr) epsilon 1.0 epsilon_min 0.05 epsilon_decay 0.995 for episode in range(episodes): state env.reset() done False episode_reward 0.0 while not done: legal env.legal_mask() action select_action(model, state, legal, epsilon, device) next_state, reward, done, _ env.step(action) # 跳过动作 reward 为 0不额外处理 buffer.append((state, action, reward, next_state, done)) if len(buffer) batch_size: batch random.sample(buffer, batch_size) states, actions, rewards, next_states, dones zip(*batch) states torch.tensor(np.array(states), dtypetorch.float32, devicedevice) next_states torch.tensor(np.array(next_states), dtypetorch.float32, devicedevice) actions torch.tensor(actions, dtypetorch.long, devicedevice).unsqueeze(1) rewards torch.tensor(rewards, dtypetorch.float32, devicedevice).unsqueeze(1) dones torch.tensor(dones, dtypetorch.float32, devicedevice).unsqueeze(1) q_values model(states).gather(1, actions) with torch.no_grad(): next_q target_model(next_states).max(1, keepdimTrue).values target rewards gamma * next_q * (1 - dones) loss F.mse_loss(q_values, target) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() state next_state episode_reward reward epsilon max(epsilon_min, epsilon * epsilon_decay) if episode % 200 0: target_model.load_state_dict(model.state_dict()) print(fep {episode}, eps {epsilon:.3f}, reward {episode_reward:.2f}) torch.save(model.state_dict(), dqn_reversi.pth)这里有几个参数值得展开说。buffer的maxlen20000黑白棋一局大概 30 到 60 步20000 条经验相当于 400 到 600 局的数据。如果设成 100 万老经验里的旧策略会长期占用采样空间导致模型更新滞后我调过发现黑白棋这种小棋类回放池 2 万到 5 万之间效果最好。epsilon_decay0.995意味着从 1.0 衰减到 0.05 大约需要 900 局前期有足够探索衰减太快比如 0.9的话模型还没学会基本规则就开始贪心容易卡在局部策略里。clip_grad_norm_是防 NaN 的保险黑白棋奖励虽小但偶尔终局奖励带来的梯度也会冲高clip 到 1.0 之后训练更稳。目标网络同步用的是“每 200 局整网络拷贝一次”。比每步软更新简单也够用。如果你用 Double DQN改法只有一行把target_model(next_states).max(1, keepdimTrue).values换成target_model(next_states).gather(1, model(next_states).argmax(1, keepdimTrue))其余不动。4.4 奖励塑形与终止条件终局胜负比每步子力差更可靠训练循环里直接用了终局胜负奖励中间步 reward 全是 0。好处是策略更接近“真正赢棋”坏处是学习慢。我的建议是毕设里可以保留一个可选参数比如给中间步加一个0.01 * action_diff其中 action_diff 是当前玩家合法落子数减去对方合法落子数每次 step 时就算出来。def _shaping_reward(self): mine self.legal_mask(self.current_player)[:64].sum() opp self.legal_mask(-self.current_player)[:64].sum() return 0.01 * (mine - opp)这个塑形奖励不会改变最优策略只是让前期探索更容易发现“落子在边角时行动力更强”的规律。注意系数 0.01 不是玄学我试过 0.1模型会为了短期子多而去送角试过 0.001等于没有。0.01 是一个既能让梯度有信号、又不会压过终局胜负的中间值。想验证这个系数合不合理可以单独关掉终局奖励跑几百局看 AI 是否还能学会吃子逻辑——能学会说明塑形成功学不会就说明中间奖励给了错误信号。5. 黑白棋强化学习训练事故避坑5 个高发翻车点5.1 现象训练 2000 局还在随机下棋先查奖励是不是只有终局一开始我预测 DQN 在黑白棋上 1000 局就能看到明显策略结果 2000 局过去AI 落子还是接近随机。查到最后发现奖励确实只有终局胜负而黑白棋单局 40 步左右终局信号太稀疏DQN 很难把“赢”归因到前几步的关键落子上。解决办法是加中间步塑形奖励用 4.4 节的行动力差或子力差小系数都可以。另一个辅助手段是减小 epsilon 衰减速度让模型更多探索我这里epsilon_decay0.998时到 3000 局 epsilon 还在 0.3 以上探索量远超够用。5.2 现象AI 总在无意义位置落子棋盘上反复送角这个翻车点最隐蔽模型训练时 loss 在下降、对局胜率也在涨但你把棋盘打出来发现 AI 经常往不能落子的位置给动作环境直接返回非法落子并原地不动。原因不是模型笨而是动作掩码根本没进argmax。很多人只在select_action的随机分支里过滤合法动作却在贪心分支忘了遮罩导致 epsilon 降到 0 之后 AI 纯粹按原始 Q 值选位置。解决把masked_fill写到select_action的贪心分支里并且每次argmax之前先打印一次非法位置的 Q 值确认它们都被压到了-1e8以下。5.3 现象训练中途 loss 直接 NaN黑白棋 reward 值很小正常情况不会 NaN。一旦出现八成是学习率设太高或者奖励里混入了奇怪的数值。有个同学的项目里把终局奖励写成了white_count - black_count棋盘填满时有几十的数值Adam 在 1e-2 学习率下直接把权重冲到 NaN。解决学习率统一用 1e-4 起步奖励尽量落在 [-1, 1] 区间如果还 NaN再看legal_mask是不是出现了无穷值。另外给梯度加clip_grad_norm_能兜底我项目里已经写进训练循环。5.4 现象模型越练越弱和上一版模型对战胜率下滑训练中期出现过一次诡异情况4000 局时模型能赢上 1000 局的版本到 6000 局反而打不赢了。我定位到经验回放池里存的全是早期随机策略的数据模型反复学习旧经验新策略带来的新经验占比太少。黑白棋自对弈时局势变化快旧经验过时得也快。解决把回放池的maxlen从 20000 再调小到 10000并且训练时要监控 sample 到的经验里的平均终止步数——如果大部分经验在 20 步内终局说明旧随机对局占主导该清池子或者重新初始化一半经验池。5.5 现象训练一开 GUI 就卡死画面撕裂这个坑和强化学习本身无关但几乎所有带界面项目都会遇到。pygame 的事件循环必须占用主线程而训练循环也在主线程跑结果就是界面每隔几秒刷新一次点击完全无响应。解决训练和渲染彻底分开。train.py不 import pygame只做纯逻辑play.py在训练结束后加载模型进入 pygame 循环每帧只采样当前棋盘状态。如果非要训练时看实时走棋把训练放到子线程pygame 事件循环留主线程但要注意Model和 tensor 操作在线程间传参会踩 PyTorch 的坑毕业设计没必要冒这个险。6. 训练完怎么验证三路对打、模型存档与命令行回放6.1 三路对打评估随机基线、上一版模型、固定策略训练完不要只跑一局看输赢。单局偶然性太大尤其黑白棋这种开局几步就影响全局的棋类一局定胜负没法作为答辩依据。我习惯固定跑 100 局分别打三种对手随机落子、上一轮保存的模型、一个最简单的贪心策略优先占角、其次占边。def evaluate(model_a, model_b, env, episodes100): model 为 None 时表示随机策略 wins 0 for _ in range(episodes): state env.reset() done False while not done: legal env.legal_mask() if env.current_player BLACK: action select_action(model_a, state, legal, epsilon0.0) else: if model_b is None: indices np.where(legal[:64] 0)[0] action int(np.random.choice(indices)) if len(indices) else 64 else: action select_action(model_b, state, legal, epsilon0.0) state, _, done, _ env.step(action) # 黑棋视角判断胜负 black int((env.board.grid BLACK).sum()) white int((env.board.grid WHITE).sum()) if black white: wins 1 return wins / episodes评估时epsilon必须传 0.0让模型完全贪心否则“随机探索”混进胜率里数据没法解释。我一般跑完把三组数据记到一个表里对随机策略胜率应该 90% 以上对上一版模型胜率应当稳步上升哪怕 52% 对 48% 也是有效进步对贪心策略如果能到 50% 以上说明 AI 已经学到比单纯占角更深的东西。6.2 一张热力图看 AI 的棋风边角控制与行动力胜率只能说明“能赢”但答辩时老师大概率会问它到底学会了什么最有说服力的是行动热力图。让训练好的模型以黑棋身份自对弈 50 局统计它第一步落子的位置分布然后画成一个 8x8 热力图。import matplotlib.pyplot as plt def action_heatmap(model, env, episodes50): heat np.zeros((8, 8)) for _ in range(episodes): state env.reset() legal env.legal_mask() action select_action(model, state, legal, epsilon0.0) if action 64: r, c divmod(action, 8) heat[r, c] 1 plt.imshow(heat, cmaphot) plt.colorbar() plt.title(First Move Distribution) plt.show()如果模型学会了黑白棋的基本棋理热力图会集中在边角附近的 6 到 12 个点位上而不是棋盘中央。黑白棋第一步落子基本都会避开中央——中央四周容易被夹翻。看到这种分布基本可以跟老师说“模型学到了边角优先的启发式策略”比贴十行 loss 曲线直观得多。6.3 模型存档与答辩演示训练完成后如何快速给老师看训练完成的模型要用torch.save(model.state_dict(), dqn_reversi.pth)单独存一份commit 进项目仓库。答辩现场不要现场训练这是铁律——设备驱动、CUDA 环境、训练时长都是不可控因素。正确做法是写一个play.py启动时加载模型进入 pygame 人机对战人类执白AI 执黑走完一步打印当前 Q 值。Q 值打印这个细节很加分老师问“它为什么这么走”你可以指着数值说“这一步让角落位置的 Q 值从 0.2 涨到 0.8”。另外我习惯在 play.py 里加一个“随机开局”按钮每次按 F2 重新开局避免每次都从标准开局走展示效果更真实。自己做完这个项目后我最深的一个习惯变化是现在做任何强化学习项目第一件事都不是搭网络而是先把环境接口和奖励函数固定下来再让网络在这个黑匣子里填参数。黑白棋这种棋类环境写对了算法再朴素也能学出东西环境写错了网络再漂亮也只是在拟合一个错误的游戏。调参过程中的大部分翻车最后都能追溯到环境边界条件和奖励信号这两个源头。希望帮到你。本文还有配套的精品资源点击获取