简介:面向山东大学软件学院2022级人工智能导论课程的实验资料包,对应软工22级7班的四次课程实验,适合正在修读该课程、需要复盘实验过程或自查实现细节的学生参考,也可作为入门神经网络实践的自学素材。压缩包共74个文件、约185.78MB,主体为11个Python脚本与4份docx实验报告,配套4组MNIST原始数据集、16个gz压缩数据与21张png结果图,另有4个按实验分装的zip源码包和1份md说明,按实验一至实验四分目录归档。内容涵盖感知机与BP网络的手写数字识别、ResNet18与CNN卷积模型搭建、CGAN图像生成等典型任务,源码可直接运行调试,实验报告则记录目的、参数设置与结果分析,读者能据此理清从数据加载、模型定义到训练评估的完整链路。目前已有200人学习下载。
1. 不是六份编程作业
人工智能导论实验在多数软件工程专业的培养方案里排在大二下学期,紧跟在概率论、线性代数和离散数学之后。很多人把它当成六份互不相干的编程作业,交完就忘;等到做课程设计或毕设、真要给系统加一个智能决策模块时,才发现搜索、评估函数、梯度下降这些东西本该在这里打通。这套方案来自某高校软件工程专业人工智能导论实验的完整整理:六个实验模块、一套统一的工程骨架、可复现的随机种子管理,以及一份能对着答辩追问逐条回答的验收清单。它适合正准备交实验报告的人,也适合想把实验内容改造成课程设计、甚至作为毕设前期原型的人——六个模块的代码量加起来不到两千行,但每一块都能独立扩展成一个完整课题。
2. 实验体系设计:六个模块的能力映射与工程量估算
课程大纲给出的是一串知识点:状态空间搜索、对抗搜索、概率推理、监督学习、神经网络。实验要交付的却是另一回事——能跑起来、能复现、能在答辩时讲清楚为什么这么设计的工程产物。从知识点到实验任务之间的翻译工作,通常没人替你做,于是最常见的做法是照着课件抄一遍伪代码,跑通了就截图交差。这套整理里必须补上这一层。
2.1 从课程知识点到可提交实验任务的拆解
六个模块按认知难度递进,前三个偏确定性算法、后三个偏统计学习,每个模块都能同时给出“跑通”和“跑好”两条验收线。下表是这套整理里我实际使用的任务定义,工程量一栏是单人独立完成的经验值。
| 模块 | 任务载体 | 核心技术点 | 代码量 | 工时 | 评分权重 |
|---|---|---|---|---|---|
| 实验一 | 八数码与迷宫寻路 | BFS / DFS / 一致代价 / A* | 约 250 行 | 6 h | 15% |
| 实验二 | 井字棋与黑白棋 | 极小极大 + α-β 剪枝 | 约 200 行 | 5 h | 15% |
| 实验三 | 文本情感分类 | 朴素贝叶斯、拉普拉斯平滑 | 约 150 行 | 4 h | 10% |
| 实验四 | 表格数据分类 | KNN、决策树、交叉验证 | 约 200 行 | 6 h | 15% |
| 实验五 | 手写数字识别 | 反向传播、梯度检查 | 约 300 行 | 10 h | 20% |
| 实验六 | 小型图像分类 | 卷积、池化、正则化 | 约 350 行 | 12 h | 25% |
拆解的关键在于让每个模块都产出三个东西:可执行脚本、可对比的指标、一段能解释指标差异的分析。只交脚本拿不到分,只交分析没有说服力。比如实验一里,八数码用 BFS 能解但展开节点数爆炸,换成 A* 之后节点数掉一个数量级,这个对比本身就是报告里最值钱的部分。实验二同理,加不加 α-β 剪枝,搜索节点数差多少,用同一局棋面跑两遍就有答案。
任务的输入输出也要提前定死,避免做到一半改接口。我一般约定:每个实验的入口是python -m exp1.main --config configs/exp1.yaml,输出统一写到results/exp1/下,包含一个metrics.json和若干图片。这样六个实验的产物结构一致,后面写报告、做汇总对比都省事。
2.2 技术选型:纯手写、调库与框架的三条路线取舍
这个环节最容易纠结。全用 NumPy 手写,能体现理解,但实验六写到卷积反向传播时会非常痛苦;全用框架调 API,三天能交完,可答辩时问一句“梯度怎么传的”就答不上来。我的做法是分模块分层:算法骨架手写,工程细节交给库。
| 路线 | 适用模块 | 优点 | 风险 |
|---|---|---|---|
| 纯 NumPy 手写 | 实验一至三、实验五 | 逻辑透明,答辩好讲 | 性能差,易出索引错误 |
| scikit-learn 调库 | 实验四、实验三对照 | 快,指标现成 | 说不清内部实现会被追问 |
| PyTorch 框架 | 实验六 | 训练稳定,可视化方便 | 容易变成调参流水线 |
具体到实验五,我的取舍是前向传播和反向传播全部手写,只用 NumPy 做矩阵运算,不引入任何自动微分。理由是这一层的数学是课程重点,手写一遍梯度公式,比看十遍推导都有用。到了实验六,卷积层的手写成本已经超出实验课时能承受的范围,这时候用 PyTorch 反而合理——但要额外补一页说明,写清楚卷积核尺寸、步长、填充怎么影响输出特征图大小,公式是 $(W - F + 2P)/S + 1$,把这一行写进报告,追问就少一半。
还有一条必须守住的底线:同一个实验里不要混用两套数据划分。有人用 sklearn 的train_test_split划分一遍,又用自己写的函数划分一遍做对比,最后两份结果的测试集有交集,指标看着漂亮,实际是自欺欺人。
2.3 统一的工程骨架与提交目录约定
六个实验共用一个仓库,目录结构固定下来,能省掉大量重复劳动。下面是我用的骨架,实测下来比每个实验建一个独立文件夹清爽得多。
ai-intro-lab/ ├── configs/ # 每个实验一份 yaml 配置 │ ├── exp1.yaml │ └── exp5.yaml ├── data/ # 原始数据只读,不提交到版本库 │ ├── raw/ │ └── splits/ # 固定的划分文件,保证可复现 ├── src/ │ ├── common/ # 种子、日志、指标、绘图 │ ├── exp1_search/ │ ├── exp5_bpnet/ │ └── exp6_cnn/ ├── results/ # 每个实验的输出目录 └── reports/ # 实验报告与图表配置外置这件事值得单独强调。把学习率、批大小、随机种子、数据路径全写进 yaml,改参数不用动代码,做对比实验时只需要复制一份配置改两行,报告里的超参数表也能直接从配置文件生成。下面这段是公共工具模块的核心部分,六个实验都会调用。
# src/common/experiment_utils.py import os, json, random, time import numpy as np def set_seed(seed: int = 42) -> None: """固定所有随机源,保证同一配置多次运行结果一致。""" random.seed(seed) # Python 内置随机 np.random.seed(seed) # NumPy 随机 os.environ["PYTHONHASHSEED"] = str(seed) try: import torch torch.manual_seed(seed) # CPU 与 GPU 的种子分开设置 torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False except ImportError: pass # 前几个实验不需要 torch def save_metrics(exp_name: str, metrics: dict) -> str: """把指标连同时间戳一起落盘,便于多次运行横向对比。""" out_dir = os.path.join("results", exp_name) os.makedirs(out_dir, exist_ok=True) metrics = dict(metrics) metrics["_timestamp"] = time.strftime("%Y-%m-%d %H:%M:%S") path = os.path.join(out_dir, "metrics.json") with open(path, "w", encoding="utf-8") as f: json.dump(metrics, f, ensure_ascii=False, indent=2) return pathset_seed里有个容易被忽略的点:torch.backends.cudnn.deterministic = True会拖慢训练速度,但实验六的数据量很小,慢几秒换来结果可复现,这笔买卖划算。save_metrics加时间戳是为了防止覆盖——同一份配置跑三次取平均,是报告里说明“结果稳定”的唯一硬证据,覆盖掉就没了。这套骨架搭完之后,每新增一个实验,只需要在configs/加一份配置、在src/加一个包,剩下的基础设施直接复用。
3. 搜索与博弈实验落地:状态建模、启发式与剪枝
搜索类实验看起来最简单,实际上是拉开差距的地方。八数码谁都能写出 BFS,但写出一个能在两秒内解出困难局面、并且能解释为什么快的实现,就需要在状态建模和启发式设计上下功夫。博弈实验同理,极小极大是模板,剪枝写对才是本事。
3.1 状态空间建模与 BFS/DFS/A* 的统一接口
我习惯先抽一个Problem基类,把“初始状态、目标判定、后继状态、单步代价”四件事定死,四种搜索算法共用同一套接口。这样做的好处是算法代码完全不知道具体是什么问题,换个迷宫问题只要重写子类,搜索部分一行不用改。
# src/exp1_search/problem.py from abc import ABC, abstractmethod class Problem(ABC): """搜索问题的统一抽象:状态用不可变对象表示,便于放进 set 去重。""" @abstractmethod def initial_state(self): ... @abstractmethod def is_goal(self, state) -> bool: ... @abstractmethod def successors(self, state): """返回 [(next_state, action, step_cost), ...]""" ... class EightPuzzle(Problem): GOAL = (1, 2, 3, 4, 5, 6, 7, 8, 0) # 0 表示空格 def __init__(self, start): self.start = tuple(start) def initial_state(self): return self.start def is_goal(self, state) -> bool: return state == self.GOAL def successors(self, state): idx = state.index(0) r, c = divmod(idx, 3) moves = [(-1, 0, "上"), (1, 0, "下"), (0, -1, "左"), (0, 1, "右")] result = [] for dr, dc, name in moves: nr, nc = r + dr, c + dc if 0 <= nr < 3 and 0 <= nc < 3: nidx = nr * 3 + nc lst = list(state) lst[idx], lst[nidx] = lst[nidx], lst[idx] result.append((tuple(lst), name, 1)) return result状态用tuple而不是list,是为了能直接塞进set做去重——用 list 会直接抛TypeError: unhashable type,这是新手第一个翻车点。successors返回三元组而不是只返回状态,是为了让一致代价搜索能读到step_cost,八数码每步代价都是 1,看起来多余,但换成迷宫带地形成本时,这个设计就体现出价值了。
A* 的实现需要注意优先队列里存什么。只存状态不行,因为要比较 f 值;只存 f 值也不行,因为要取状态。我的做法是存(f, g, counter, state),counter是自增计数器,纯粹为了在 f 相等时避免比较状态元组导致报错——两个元组之间没法比大小,Python 会抛异常。这个问题在节点数少的时候不一定触发,一旦跑到上万节点就必然出现,属于典型的“小数据测试通过、大数据崩溃”。
| 算法 | 数据结构 | 完备性 | 最优性 | 八数码平均展开节点 |
|---|---|---|---|---|
| BFS | 队列 | 是 | 是(单位代价) | 约 15000 |
| DFS | 栈 | 否(需限深) | 否 | 平均 3000 但解很长 |
| 一致代价 | 优先队列 | 是 | 是 | 约 14000 |
| A*(曼哈顿) | 优先队列 | 是 | 是 | 约 300 |
这张表建议直接放进报告。节点数是同一批测试用例的平均值,跑一遍就有,比任何文字描述都有说服力。
3.2 启发式函数设计与可采纳性验证
A* 之所以快,全靠启发式函数。八数码常用的两个:错位棋子数(每个不在目标位的棋子计 1)和曼哈顿距离(每个棋子当前位置到目标位置的横纵距离之和)。曼哈顿距离更紧,展开节点更少,这是因为前者是后者的下界。
可采纳性(admissible)是 A* 保证最优解的前提:启发式估计值不能超过真实剩余代价。这个性质不是靠嘴说的,我一般让学生写一段抽样验证——随机采若干状态,用 BFS 算出真实最短距离,再和启发式值比,全部满足h(n) <= h*(n)才算过关。
# src/exp1_search/heuristic.py from collections import deque def manhattan(state) -> int: """曼哈顿距离:每个数字块到目标位置的横纵距离之和。""" total = 0 for i, v in enumerate(state): if v == 0: continue goal_r, goal_c = divmod(v - 1, 3) # 数字 v 的目标行列 cur_r, cur_c = divmod(i, 3) total += abs(goal_r - cur_r) + abs(goal_c - cur_c) return total def misplaced(state) -> int: """错位棋子数,恒小于等于曼哈顿距离,也是可采纳的。""" return sum(1 for i, v in enumerate(state) if v != 0 and v != EightPuzzle.GOAL[i]) def verify_admissible(problem, heuristic, samples=200): """抽样比对:启发式值不得超过 BFS 求出的真实最短距离。""" import random violations = 0 for _ in range(samples): state = problem.initial_state() for _ in range(random.randint(5, 20)): # 随机游走制造测试局面 succ = problem.successors(state) if succ: state = random.choice(succ)[0] true_cost = bfs_distance(problem, state) # 用 BFS 求真实最短路 if true_cost is not None and heuristic(state) > true_cost: violations += 1 return violationsmisplaced里排除空格是必须的:空格不属于任何数字,它的位置不该计入错位数,漏掉这个判断会让启发式偏大,虽然有概率仍然可采纳,但已经失去了理论保证。verify_admissible返回 0 才敢在报告里写“本实验所用启发式满足可采纳性”,这个数字本身就是答辩时的挡箭牌。
还有一类非可采纳但更快的启发式,比如把曼哈顿距离乘个权重(weighted A*),能在更短时间内找到解,但不再保证最优。这个思路值得写进报告的“扩展讨论”一节,说明自己知道最优性和效率之间的权衡,比单纯刷分更能体现理解。
3.3 博弈树与 alpha-beta 剪枝的工程实现
井字棋的搜索空间小,正好用来验证剪枝逻辑是否正确。核心是极小极大值的递归:轮到己方走取最大值,轮到对方走取最小值,α 记录当前路径上己方能保证的下界,β 记录对方能保证的上界,一旦α >= β就直接剪掉。
# src/exp2_game/alphabeta.py WIN_LINES = [(0,1,2),(3,4,5),(6,7,8),(0,3,6),(1,4,7),(2,5,8),(0,4,8),(2,4,6)] _stats = {"nodes": 0, "cuts": 0} # 统计剪枝效果,报告里要用 def evaluate(board, player) -> int: """终局评估:己方赢返回正分,越早赢分数越高,平局为 0。""" for a, b, c in WIN_LINES: if board[a] == board[b] == board[c] != 0: return 10 * board[a] * player if 0 not in board: return 0 return None # 未终局 def alphabeta(board, player, depth, alpha, beta, max_depth=9): """player: 1 表示 X,-1 表示 O。返回 (分数, 最佳落子)。""" _stats["nodes"] += 1 score = evaluate(board, player) if score is not None: return score, None if depth >= max_depth: return 0, None best_move, best_score = None, -float("inf") for i in range(9): if board[i] != 0: continue board[i] = player # 关键:换手后对对手调用,取反得到己方视角分数 opp_score, _ = alphabeta(board, -player, depth + 1, -beta, -alpha, max_depth) board[i] = 0 if -opp_score > best_score: best_score, best_move = -opp_score, i alpha = max(alpha, best_score) if alpha >= beta: # 剪枝:对手不会允许走到这个分支 _stats["cuts"] += 1 break return best_score, best_move三个地方最容易写错。第一是-beta, -alpha这个参数交换,用的是负极大值形式,把“对方取最小”转成“己方取最大后取负”,代码短了但极容易把符号搞反;如果发现 AI 下棋像在帮对手,八成是这里写错了。第二是alpha的更新必须放在循环内,放在循环外等于没剪枝。第三是评估函数里的player乘子,漏乘会导致双方都认为自己是赢家,棋力直接归零。我一般让学生先用不加剪枝的版本跑一遍,记录节点数,再加上剪枝跑一遍,两个数字对比写进报告——井字棋上通常能砍掉一半以上节点,这个比例就是剪枝有效性的直接证据。
4. 学习类实验落地:从 KNN 到 BP 网络再到 CNN 识别
从确定性算法跨到统计学习,最大的变化是“结果不再唯一”。同一个数据集跑两遍准确率差三个点,是玄学还是错误?这一章要解决的就是把不确定性关进笼子里,让每一次指标波动都有解释。
4.1 数据管线与特征工程的统一约定
学习类实验的第一步永远是数据,不是模型。我强制要求把数据划分固定成文件,而不是每次运行时随机切分——随机切分意味着每次跑出来的测试集都不一样,指标根本没有可比性。
# src/common/data_pipeline.py import numpy as np def make_split(n_samples: int, ratio=(0.6, 0.2, 0.2), seed=42): """一次性生成划分索引并保存,后续所有实验复用同一份。""" rng = np.random.RandomState(seed) idx = rng.permutation(n_samples) n_train = int(n_samples * ratio[0]) n_val = int(n_samples * ratio[1]) return { "train": idx[:n_train], "val": idx[n_train:n_train + n_val], "test": idx[n_train + n_val:], } def standardize(train_x, other_x): """用训练集统计量标准化,防止测试集信息泄漏。""" mu = train_x.mean(axis=0) sigma = train_x.std(axis=0) + 1e-8 # 防止除零 return (train_x - mu) / sigma, (other_x - mu) / sigmastandardize这个函数看着简单,却是数据泄漏最高发的地方。正确的做法是只在训练集上算均值和标准差,然后把这组统计量应用到验证集和测试集;如果对全体数据一起标准化,测试集的分布信息就提前泄露进模型,指标会虚高,而且高得不多,很难被发现。同样的道理适用于归一化、PCA、特征选择,凡是“需要拟合的预处理”,一律只在训练集上 fit。
4.2 KNN 与决策树的基线实现与对比
实验四的定位是建立基线。任何一个复杂模型,都应该先和一个简单模型比,比不过就说明复杂模型没调好。KNN 和决策树恰好代表两种极端:前者没有训练过程,全靠距离度量;后者靠递归划分,可解释性强。
| 对比项 | KNN(k=5) | 决策树(max_depth=8) |
|---|---|---|
| 训练耗时 | 几乎为 0 | 约 0.3 s |
| 预测耗时 | 随样本数线性增长 | 常数级 |
| 对特征尺度 | 极度敏感 | 不敏感 |
| 可解释性 | 弱 | 强(可导出规则) |
| 典型准确率 | 0.92 | 0.89 |
KNN 对尺度敏感这一点必须在报告里体现:不做标准化直接跑,准确率往往掉五到十个点,因为数值范围大的特征主导了距离计算。这个对比实验花不了几分钟,却是理解“为什么预处理重要”最直观的例子。决策树的深度要限制,不限制的话它会把训练集背下来,训练准确率 100%、测试准确率 80%,典型的过拟合现场,正好用来讲正则化的必要性。
4.3 手写 BP 网络:前向、反向与梯度检查
实验五是整个课程的分水岭。要求用 NumPy 手写一个两层全连接网络做手写数字识别,输入 784 维,隐藏层 128,输出 10 类,激活函数用 ReLU,损失用交叉熵。
# src/exp5_bpnet/mlp.py import numpy as np class MLP: def __init__(self, n_in=784, n_hidden=128, n_out=10, seed=42): rng = np.random.RandomState(seed) # He 初始化,适配 ReLU,避免一开始就大面积失活 self.W1 = rng.randn(n_in, n_hidden) * np.sqrt(2.0 / n_in) self.b1 = np.zeros(n_hidden) self.W2 = rng.randn(n_hidden, n_out) * np.sqrt(2.0 / n_hidden) self.b2 = np.zeros(n_out) def forward(self, x): self.x = x self.z1 = x @ self.W1 + self.b1 self.a1 = np.maximum(0, self.z1) # ReLU self.z2 = self.a1 @ self.W2 + self.b2 # softmax 前先减最大值,防止指数溢出 exp = np.exp(self.z2 - self.z2.max(axis=1, keepdims=True)) self.prob = exp / exp.sum(axis=1, keepdims=True) return self.prob def backward(self, y_onehot, lr=0.01): n = y_onehot.shape[0] dz2 = (self.prob - y_onehot) / n # 交叉熵 + softmax 的合并梯度 dW2 = self.a1.T @ dz2 db2 = dz2.sum(axis=0) da1 = dz2 @ self.W2.T dz1 = da1 * (self.z1 > 0) # ReLU 导数 dW1 = self.x.T @ dz1 db1 = dz1.sum(axis=0) self.W1 -= lr * dW1 self.b1 -= lr * db1 self.W2 -= lr * dW2 self.b2 -= lr * db2dz2 = (prob - y_onehot) / n这一行是整段代码的核心,交叉熵配 softmax 的梯度化简后就是这么干净,手推一遍能省掉大量调试时间。self.z2 - self.z2.max(...)是数值稳定性处理,不加的话在训练后期 logits 变大时会出现nan,而且往往在跑到第几十个 epoch 才出现,属于最难定位的那类 bug。
写完反向传播必须做梯度检查,这是唯一能验证推导对不对的手段。思路是取一个参数,用极小的扰动算数值梯度,和解析梯度比对相对误差。
def grad_check(model, x, y_onehot, eps=1e-5): """只抽查 W2 的部分元素,全量检查太慢。""" model.forward(x) model.backward(y_onehot, lr=0.0) # lr=0 表示只算梯度不更新 analytic = model.a1.T @ ((model.prob - y_onehot) / x.shape[0]) max_err = 0.0 for _ in range(10): i = np.random.randint(0, model.W2.shape[0]) j = np.random.randint(0, model.W2.shape[1]) orig = model.W2[i, j] model.W2[i, j] = orig + eps loss_plus = -np.log(model.forward(x)[np.arange(len(y_onehot)), y_onehot.argmax(1)] + 1e-12).mean() model.W2[i, j] = orig - eps loss_minus = -np.log(model.forward(x)[np.arange(len(y_onehot)), y_onehot.argmax(1)] + 1e-12).mean() model.W2[i, j] = orig numeric = (loss_plus - loss_minus) / (2 * eps) denom = max(abs(numeric), abs(analytic[i, j]), 1e-8) max_err = max(max_err, abs(numeric - analytic[i, j]) / denom) return max_err # 相对误差小于 1e-6 认为推导正确相对误差的判据要写成abs(a-b)/max(abs(a),abs(b),1e-8),直接用绝对误差会因为梯度量级差异误判。lr=0.0是为了在检查时不污染权重,这个细节很多人忽略,检查完发现模型被改坏了,白跑一轮训练。
4.4 小型 CNN 训练与实验记录规范
实验六用 PyTorch 做小规模图像分类,两层卷积加两层全连接就够,重点不在刷准确率,而在把训练过程记录清楚:每个 epoch 的训练损失、验证损失、验证准确率、学习率,全部落盘。
# src/exp6_cnn/train.py import torch def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct, total = 0.0, 0, 0 for x, y in loader: x, y = x.to(device), y.to(device) optimizer.zero_grad(set_to_none=True) # 比 zero_grad() 稍快 logits = model(x) loss = criterion(logits, y) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() total_loss += loss.item() * x.size(0) correct += (logits.argmax(1) == y).sum().item() total += x.size(0) return total_loss / total, correct / totalclip_grad_norm_是防梯度爆炸的后悔药,学习率设大了、数据里有异常样本时,不加这一行很容易在第几个 epoch 突然 loss 变nan。set_to_none=True是个小优化,把梯度置为 None 而不是零张量,显存和速度都略好,属于知道了就会一直用的习惯。
记录规范上,我要求报告里必须有一张“训练曲线 + 关键超参”的对照图。学习率 0.1、0.01、0.001 各跑一次,三条曲线画在一张图上,收敛速度和最终精度一眼可见。这比写一大段“经过调参,模型效果良好”有用得多,也是答辩老师最爱看的东西。
5. 避坑与排查:实验里最容易翻车的五个点
这些坑的共同特点是:报错信息不明确,或者根本不报错,只是结果不对。整理出来按“现象、原因、解决”三步走,出问题时对着排查能省下大半时间。
5.1 结果不可复现与数据泄漏
现象一:同一份代码跑两次,准确率差三到五个点。原因几乎总是随机种子没固定全,或者数据加载顺序受多线程影响。Python 的random、NumPy、框架各自的随机源是独立的,只固定其中一个等于没固定。解决方式就是第 2 章那个set_seed,并且在配置文件里把种子写成显式参数,而不是藏在代码里。
现象二:测试集准确率高得离谱,换一批数据就崩。原因是在划分数据之前做了标准化、归一化或者特征选择,测试集的信息提前进入了预处理环节。解决方式是把所有fit类操作限制在训练集上,验证集和测试集只做transform。这个错误特别隐蔽,因为指标只是“稍微好一点”,不会好到让人怀疑。
5.2 搜索与博弈类算法的逻辑陷阱
现象三:A找到的解比 BFS 长。* 原因通常是启发式不可采纳,或者优先队列在 f 值相等时对比了状态对象导致节点丢失。解决方式是先用第 3 章那段抽样验证跑一遍可采纳性,再检查优先队列里是否放了自增计数器。还有一种可能是把 g 值和 f 值搞混,弹出队列时用了g排序。
现象四:AI 下棋水平还不如随机。原因基本锁定在评估函数符号或者-beta, -alpha的参数交换。解决方式很朴素:把搜索深度限制为 1,看它会不会走“下一步就赢”的位置;如果不会,说明符号反了,把评估函数的player乘子去掉再试。
5.3 训练过程中的数值问题
现象五:损失降到一半突然变成nan。三种常见原因:学习率过大、softmax 没有做减最大值处理、梯度爆炸。排查顺序是先打印每个 batch 的损失,找到第一次出现nan的位置;再看那一批数据的输入范围是否异常;最后加梯度裁剪并把学习率降一个数量级。我一般会把torch.isnan(loss)的判断写进训练循环,一旦命中就打印当前 batch 的统计量并中断,比跑到最后看到一个nan强得多。
提示:以上五条里,现象一和现象二属于工程习惯问题,现象三到五属于理论理解问题。前者靠模板代码就能规避,后者只能靠推导加验证,别指望复制粘贴解决。
6. 验收与答辩:让实验报告经得起追问
验收环节真正被问倒的,往往不是“这个算法是什么”,而是“你为什么不那样做”。所以准备的重点不是背概念,是给每个设计决策准备一个理由,并且这个理由最好有数据支撑。
6.1 验收清单与评分维度对齐
下面这张表是我按评分维度整理的自查项,交之前逐条过一遍,能挡掉绝大部分扣分点。
| 维度 | 检查项 | 佐证材料 |
|---|---|---|
| 正确性 | 算法输出与理论预期一致 | 小规模手工验算样例 |
| 可复现 | 换机器同配置结果一致 | 固定种子 + 划分文件 + 依赖版本 |
| 完整性 | 六个模块均有指标输出 | metrics.json 与图表 |
| 分析深度 | 至少一组对照实验 | 有/无剪枝、标准化前后、不同学习率 |
| 工程规范 | 配置外置、日志完整、目录统一 | 仓库结构与配置样例 |
最容易漏的是“分析深度”。很多人六个实验都跑通了,但每个都只有一组结果,报告读起来像流水账。补救办法很便宜:每个实验补一组对照,搜索类补剪枝对比,学习类补超参对比,半小时的活,报告质量立刻上一个台阶。
6.2 答辩现场的三类高频追问与应答思路
第一类是“为什么选这个启发式”。答案不能是“因为常见”,要落到可采纳性和紧致度上:曼哈顿距离是错位数量的下界,更紧所以展开更少,并且有抽样验证数据。第二类是“你怎么保证不是碰巧”。答案是固定种子跑多次取均值方差,加上固定的数据划分文件,这两样东西拿出来,质疑基本就结束了。第三类是“如果数据量放大十倍会怎样”。这类问题考的是复杂度意识,KNN 预测是线性复杂度会变慢,A* 的内存占用会爆炸,手写 BP 的全批量梯度下降会跑不动——能说出瓶颈在哪、该怎么改,比给出确定答案更重要。
我印象最深的一次是某同学被问到为什么 KNN 要标准化,他答“因为教程里都这么做”。这个回答直接把一个送分题变成了扣分题。后来我自己带实验时定了个规矩:任何一行预处理代码,如果讲不出“不做会怎样”,就删掉重写。
从那以后我每次交实验报告前,都强制自己走一遍“删代码测试”——把某个处理步骤注释掉,跑一次,看指标掉多少。掉得多的写进报告当论据,掉不动的说明这步本来就没必要。这个方法很土,但比任何自查清单都管用。希望帮到你。
本文还有配套的精品资源,点击获取