☰
基于Python的黑白棋强化学习实战:从游戏逻辑到DQN训练全解析
2026/10/11 21:31:39 网站建设 项目流程

简介:一份基于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), dtype=np.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,而是 64+1。

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), dtype=np.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, player=None): player = self.current_player if player is None else player mask = np.zeros(65, dtype=np.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], axis=0)

player是当前行动方,own和opp都是相对当前玩家而言的,这样黑棋和白棋用同一个网络,只是输入特征互换。你也可以再加一个通道表示合法落子位置,但那个信息在动作选择阶段已经用到了,估 Q 值时网络不一定需要另外看到。我试过四通道效果和三通道差不多,三通道还省一点参数。编码完的 shape 是(3, 8, 8),PyTorch 默认的卷积输入也是(C, H, W),可以直接喂网络。

4.2 DQN 网络结构与动作掩码

黑白棋棋盘只有 8x8,不需要太大网络。我用的是两层卷积加两层全连接,参数少、训练快,效果已经够用。结构上唯一特别的是输出维度为 65:64 个落子位置加 1 个跳过动作。

import torch import torch.nn as nn import torch.nn.functional as F class DQN(nn.Module): def __init__(self, in_channels=3): super().__init__() self.conv = nn.Sequential( nn.Conv2d(in_channels, 32, 3, padding=1), nn.ReLU(), nn.Conv2d(32, 64, 3, padding=1), 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, device='cpu'): """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, dtype=torch.float32, device=device).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, device=device) 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, episodes=3000, batch_size=64, gamma=0.99, lr=1e-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(maxlen=20000) optimizer = torch.optim.Adam(model.parameters(), lr=lr) 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), dtype=torch.float32, device=device) next_states = torch.tensor(np.array(next_states), dtype=torch.float32, device=device) actions = torch.tensor(actions, dtype=torch.long, device=device).unsqueeze(1) rewards = torch.tensor(rewards, dtype=torch.float32, device=device).unsqueeze(1) dones = torch.tensor(dones, dtype=torch.float32, device=device).unsqueeze(1) q_values = model(states).gather(1, actions) with torch.no_grad(): next_q = target_model(next_states).max(1, keepdim=True).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(f"ep {episode}, eps {epsilon:.3f}, reward {episode_reward:.2f}") torch.save(model.state_dict(), "dqn_reversi.pth")

这里有几个参数值得展开说。buffer的maxlen=20000:黑白棋一局大概 30 到 60 步,20000 条经验相当于 400 到 600 局的数据。如果设成 100 万,老经验里的旧策略会长期占用采样空间,导致模型更新滞后;我调过发现黑白棋这种小棋类,回放池 2 万到 5 万之间效果最好。epsilon_decay=0.995意味着从 1.0 衰减到 0.05 大约需要 900 局,前期有足够探索;衰减太快(比如 0.9)的话,模型还没学会基本规则就开始贪心,容易卡在局部策略里。clip_grad_norm_是防 NaN 的保险,黑白棋奖励虽小,但偶尔终局奖励带来的梯度也会冲高,clip 到 1.0 之后训练更稳。

目标网络同步用的是“每 200 局整网络拷贝一次”。比每步软更新简单,也够用。如果你用 Double DQN,改法只有一行:把target_model(next_states).max(1, keepdim=True).values换成target_model(next_states).gather(1, model(next_states).argmax(1, keepdim=True)),其余不动。

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_decay=0.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, episodes=100): """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, epsilon=0.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, epsilon=0.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, episodes=50): heat = np.zeros((8, 8)) for _ in range(episodes): state = env.reset() legal = env.legal_mask() action = select_action(model, state, legal, epsilon=0.0) if action < 64: r, c = divmod(action, 8) heat[r, c] += 1 plt.imshow(heat, cmap='hot') 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 重新开局,避免每次都从标准开局走,展示效果更真实。

自己做完这个项目后,我最深的一个习惯变化是:现在做任何强化学习项目,第一件事都不是搭网络,而是先把环境接口和奖励函数固定下来,再让网络在这个黑匣子里填参数。黑白棋这种棋类,环境写对了,算法再朴素也能学出东西;环境写错了,网络再漂亮也只是在拟合一个错误的游戏。调参过程中的大部分翻车,最后都能追溯到环境边界条件和奖励信号这两个源头。希望帮到你。

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

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

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

立即咨询