简介:面向山东大学软件学院2022级软件工程专业学生的人工智能导论课程实验资料,覆盖实验一至实验四的完整源码、实验报告与运行结果,适合正在修读人工智能导论、需要复现课程实验或梳理基础知识的学生参考。压缩包共74个文件、约185.78MB,以Python脚本、docx实验报告、png结果图和gz压缩数据为主,并附带MNIST手写数字识别的ubyte训练与测试数据,可支撑BP神经网络、CGAN生成模型、ResNet18与CNN等实验内容的代码阅读和结果对照。已有200人学习。资料按实验分目录组织,源码、报告和输出图像相互对应,便于读者理解每个实验的任务要求、模型搭建思路、训练流程与结果分析,也能为实验报告撰写、参数调整和排错提供直接参照,适合课程学习、实验复现与期末复习使用。
1. 从一份跑不起来的实验包说起
多数人拿到人工智能导论实验资源包的第一反应,是直接翻到报告模板抄结论,结果代码一跑就报错,或者跑出来的准确率和指导书对不上。这份资源包来自某高校软件学院的人工智能导论课程实验,按「搜索算法 → 传统机器学习 → 神经网络」三段推进,配套实验指导、参考实现、本地数据集和报告模板。它真正解决的问题不是「交作业」,而是让你把 BFS/A*、KNN/决策树/朴素贝叶斯、BP 网络这三类最基础的东西从公式落到能跑、能解释、能改参数的一行行代码上。适合两类人:正在做课程实验但只会调包的同学,以及想补齐 AI 基础但在职多年、需要一条最短上手路径的开发者。下面按我拆包的顺序讲,先搭可复现的工程骨架,再逐个实验拆实现和参数。
2. 工程骨架:把实验包变成能反复跑的实验台
2.1 目录约定与依赖隔离
实验包最容易被忽略的是目录约定。常见做法是按实验编号分目录,每个目录自带数据、代码、输出三块,公用的评估函数抽到顶层common/。我一般会把结构固定成下面这样,后面所有脚本都按这个约定找文件,换机器不用改路径:
ai-lab/ ├── common/ # 跨实验复用:随机种子、评估、绘图 │ ├── seed.py │ └── metrics.py ├── exp01_search/ # 搜索算法实验 │ ├── data/ # 初始状态样例 │ ├── eight_puzzle.py │ └── out/ # 路径回放图、搜索节点数 ├── exp02_ml/ # 传统机器学习实验 │ ├── data/iris.csv │ └── eval_models.py ├── exp03_nn/ # 神经网络实验 │ ├── data/digits.csv │ └── bp_digits.py ├── requirements.txt └── run_all.py # 一键入口依赖隔离这一步不要省。系统里装了三四个版本的 numpy,报错会非常隐蔽。虚拟环境建好后按requirements.txt装,具体版本号以指导书为准,不要随手升到最新:
# 在项目根目录执行 python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt python -c "import numpy, sklearn; print(numpy.__version__)"requirements.txt里把 numpy、scikit-learn、matplotlib、torch 都列上,即使某个实验暂时用不到。原因很实际:实验之间会互相 import,缺一个包会在很靠后的步骤才炸,排查成本远高于一次装全。最后那条python -c是为了确认虚拟环境真的生效,而不是装到了全局。
2.2 随机种子三件套:解决「每次结果都不一样」
实验报告里最常见的翻车是:同一份代码跑两次,准确率差 3 个点,截图对不上。这不是玄学,是三个随机源没锁住——Python 标准库的random、numpy 的全局随机状态、以及深度学习框架的初始化与算子。
# common/seed.py import os import random import numpy as np SEED = 20240501 def seed_everything(seed: int = SEED) -> None: """把多个随机源锁到同一种子,保证同机多次运行结果一致""" os.environ["PYTHONHASHSEED"] = str(seed) # 影响 set/dict 遍历顺序 random.seed(seed) # random.shuffle 等 np.random.seed(seed) # np.random.* 全局接口 try: import torch torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True # 关掉非确定性卷积 torch.backends.cudnn.benchmark = False except ImportError: pass # 纯 numpy 实验不需要 torch def make_rng(seed: int = SEED) -> np.random.Generator: """新式接口,推荐在函数内部使用,避免污染全局状态""" return np.random.default_rng(seed)三个参数要理解清楚:PYTHONHASHSEED必须在解释器启动前设置才生效,所以在脚本里设它只对子进程有用,真正稳妥的是在运行命令前 export;cudnn.deterministic=True会牺牲一点速度换可复现,做实验阶段值得;make_rng是新式写法,函数内部初始化权重时优先用它,这样即使外部调了np.random.seed也不会互相干扰。
我一般的习惯是:纯 numpy 的 BP 实验用make_rng,sklearn 的实验在train_test_split、cross_val_score里显式传random_state,两者都不依赖全局种子。全局的seed_everything只作为兜底。
2.3 一键入口:让「跑通全部实验」变成一条命令
实验包里有四五个脚本,逐个python xxx.py很容易漏跑。写一个入口脚本,每个实验模块统一暴露run(seed)函数,入口负责调度和异常隔离——某个实验炸了不影响后面的继续跑。
# run_all.py import argparse import importlib import traceback EXPERIMENTS = { "search": "exp01_search.eight_puzzle", "knn": "exp02_ml.eval_models", "tree": "exp02_ml.eval_models", "bp": "exp03_nn.bp_digits", } def main(): parser = argparse.ArgumentParser() parser.add_argument("--only", nargs="*", choices=list(EXPERIMENTS), help="只跑指定实验,默认全跑") parser.add_argument("--seed", type=int, default=20240501) args = parser.parse_args() targets = args.only or list(EXPERIMENTS) failed = [] for name in targets: try: module = importlib.import_module(EXPERIMENTS[name]) module.run(seed=args.seed) # 统一入口,参数只传 seed print(f"[OK] {name}") except Exception: failed.append(name) print(f"[FAIL] {name}") traceback.print_exc() # 打印完整栈,方便定位 print(f"完成 {len(targets) - len(failed)}/{len(targets)},失败:{failed}") if __name__ == "__main__": main()--only用 nargs 接收多个值,调试单个实验时不用改代码;traceback.print_exc()比print(e)重要得多,实验包里的错误往往是数据形状不匹配,只有完整栈才看得出是哪一行。每个实验模块里必须定义一个签名一致的run(seed),这是入口脚本能工作的前提,也是我在拆包时最先检查的地方。
3. 搜索算法实验:八数码从 BFS 到 A* 的三种写法
3.1 状态表示与判重:为什么用元组而不用列表
八数码实验的核心不是算法,是状态表示。3×3 棋盘用长度 9 的一维序列表示,0 代表空格,目标状态固定为(1,2,3,4,5,6,7,8,0)。这里必须用元组:列表不可哈希,放进set判重会直接抛TypeError;而搜索算法的性能瓶颈几乎全在判重上。
# exp01_search/eight_puzzle.py from collections import deque import heapq GOAL = (1, 2, 3, 4, 5, 6, 7, 8, 0) # 0 表示空格 def neighbors(state): """生成合法后继:返回 (新状态, 移动方向),方向仅用于日志和回放""" i = state.index(0) r, c = divmod(i, 3) for dr, dc, tag in ((-1, 0, "上"), (1, 0, "下"), (0, -1, "左"), (0, 1, "右")): nr, nc = r + dr, c + dc if 0 <= nr < 3 and 0 <= nc < 3: j = nr * 3 + nc nxt = list(state) nxt[i], nxt[j] = nxt[j], nxt[i] yield tuple(nxt), tagdivmod(i, 3)把一维下标拆成行列,是所有边界判断的基础;越界判断写成0 <= nr < 3而不是abs(nr-r) <= 1,前者更直观也更少出错。yield而不是返回列表,是因为 BFS 队列里状态数量可能到几十万,生成器能省下大量临时对象。
3.2 BFS 与 DFS 的边界:深度上限和内存
BFS 保证最短路径,代价是内存。八数码从随机可解状态到目标,平均解长度在 20 步上下,队列峰值能到十几万状态,每个状态带一条完整路径时内存会翻几倍。所以参考实现通常加一个max_depth参数,超过就剪掉。
def bfs(start, max_depth=30): if start == GOAL: return [start] seen = {start} q = deque([(start, [start])]) while q: state, path = q.popleft() if len(path) - 1 >= max_depth: continue # 超过深度上限不再扩展 for nxt, _ in neighbors(state): if nxt not in seen: if nxt == GOAL: return path + [nxt] seen.add(nxt) q.append((nxt, path + [nxt])) return Nonemax_depth设 30 是经验值:再大内存容易吃紧,再小有些状态搜不到。seen在入队时就加入而不是出队时,避免同一状态被重复压入队列——这是新手最容易写错的一处,出队判重会让队列膨胀数倍。DFS 版本把popleft换成pop即可,但要注意必须配合深度上限和路径去重,否则会在环上打转。
3.3 A* 与启发式:曼哈顿距离什么时候可采纳
A* 的关键是启发函数。八数码用曼哈顿距离:每个数字当前位置到目标位置的横纵距离之和,空格不计。它满足可采纳性(永不高估真实代价),所以weight=1时结果一定最优。
def manhattan(state): dist = 0 for idx, val in enumerate(state): if val == 0: continue gr, gc = divmod(val - 1, 3) # 目标位置(零基) r, c = divmod(idx, 3) dist += abs(r - gr) + abs(c - gc) return dist def astar(start, weight=1.0): """weight=1 标准 A*,结果最优;weight>1 为加权 A*,更快但不保证最优""" open_heap = [(weight * manhattan(start), 0, start)] g = {start: 0} parent = {start: None} while open_heap: _, cost, state = heapq.heappop(open_heap) if state == GOAL: path = [] while state is not None: path.append(state) state = parent[state] return path[::-1] if cost > g.get(state, float("inf")): continue # 堆中存在该状态的旧记录,跳过 for nxt, _ in neighbors(state): ng = cost + 1 if ng < g.get(nxt, float("inf")): g[nxt] = ng parent[nxt] = state heapq.heappush(open_heap, (ng + weight * manhattan(nxt), ng, nxt)) return None三个参数逐一说:weight是启发式权重,取 1 是最优解、取 2 到 5 会明显减少扩展节点数但路径可能不是最短,实验报告里做对比正好;堆元素里的第二项cost用来判断这条记录是否已过期,惰性删除比在堆里查找删除简单得多;g字典存的是到每个状态的最小实际代价,没有它 A* 会退化成带优先级的 BFS。
如果你把目标状态换成一个非标准布局,比如空格在左上角,曼哈顿距离里val - 1的映射就要跟着改,否则启发式会系统性高估,A* 的结果直接不最优。这个坑我在第一次改实验参数时踩过。
3.4 结果验证:路径回放与最优性检查
搜索结果不能只看「返回了非 None」。写一个回放函数,检查每一步是不是合法移动、终点是不是目标,顺便把步数打出来和 BFS 对比。
def replay(path, goal=GOAL): """回放路径:校验合法性并返回步数,非法直接断言失败""" assert path and path[0] == path[0], "路径为空" for a, b in zip(path, path[1:]): assert b in (s for s, _ in neighbors(a)), "存在非法移动" assert path[-1] == goal, "终点不是目标状态" return len(path) - 1对于可解状态,A*(weight=1)和 BFS 的步数必须相等,这是最优性的直接证据;加权 A* 步数可能更多,但扩展节点数应该明显更少。实验报告里把「扩展节点数」和「路径长度」做成一张表,比只贴一张结果截图有说服力得多。
4. 机器学习与神经网络:从 KNN 到 BP 的一条主线
4.1 数据切分与标准化:先切后标,避免泄漏
传统机器学习实验最容易出的问题是数据泄漏,而泄漏八成来自标准化的位置。正确顺序是:先切训练集/测试集,再在训练集上 fit 标准化器,最后 transform 测试集。
# exp02_ml/common.py import numpy as np from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler def load_csv(path): """本地 csv:最后一列是标签,其余是特征;跳过表头""" data = np.loadtxt(path, delimiter=",", skiprows=1) return data[:, :-1], data[:, -1].astype(int) def split_and_scale(X, y, seed=20240501, test_size=0.3): X_tr, X_te, y_tr, y_te = train_test_split( X, y, test_size=test_size, random_state=seed, stratify=y ) scaler = StandardScaler().fit(X_tr) # 只在训练集上 fit return scaler.transform(X_tr), scaler.transform(X_te), y_tr, y_testratify=y保证训练集和测试集的类别比例一致,小数据集上尤其重要,否则某类样本可能全被分到一边;random_state必须固定,不然每次切分不同,交叉验证的数字没有可比性。如果在全量数据上 fit 再切分,测试集的均值方差信息就漏进了训练过程,准确率会虚高——这就是数据泄漏最典型的形态。
4.2 KNN、决策树、朴素贝叶斯的统一评估
三个模型放在一起比较,关键是评估口径一致:同一个切分、同一套交叉验证折数、同一个打分函数。
# exp02_ml/eval_models.py from sklearn.neighbors import KNeighborsClassifier from sklearn.tree import DecisionTreeClassifier from sklearn.naive_bayes import GaussianNB from sklearn.model_selection import StratifiedKFold, cross_val_score from common import seed # 复用 common/seed.py 的 make_rng def run(seed_value=20240501): from .common import load_csv, split_and_scale X, y = load_csv("exp02_ml/data/iris.csv") X_tr, X_te, y_tr, y_te = split_and_scale(X, y, seed=seed_value) models = { "knn_k5": KNeighborsClassifier(n_neighbors=5), "tree": DecisionTreeClassifier(max_depth=4, random_state=seed_value), "gnb": GaussianNB(), } cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=seed_value) for name, model in models.items(): scores = cross_val_score(model, X_tr, y_tr, cv=cv, scoring="accuracy") print(f"{name:8s} cv={scores.mean():.4f} ± {scores.std():.4f}") model.fit(X_tr, y_tr) print(f"{'':8s} test={model.score(X_te, y_te):.4f}")参数说明:n_neighbors=5是 KNN 的默认起点,样本量小时改成 3 或 7 做对比;max_depth=4是给决策树加的剪枝上限,不加的话训练集准确率能到 1.0 而测试集掉下来,正好用来演示过拟合;shuffle=True配合固定随机种子,保证每次折分一致。输出里同时给交叉验证均值和标准差,比单个测试集数字稳,因为大数据集上单次切分的方差可能超过模型之间的差距。
KNN 对尺度敏感,必须用标准化后的特征;决策树和朴素贝叶斯对尺度不敏感,但用同一份标准化数据不影响结论,评估口径反而更干净。
4.3 手写数字的 BP:用 numpy 写一层隐藏层
神经网络实验一般给两个版本:框架版和纯 numpy 版。纯 numpy 版更能说明反向传播在做什么,也更容易在报告里讲清楚梯度从哪来。
# exp03_nn/bp_digits.py import numpy as np def one_hot(y, k=10): out = np.zeros((y.size, k)) out[np.arange(y.size), y] = 1 return out def train_bp(X, y, hidden=64, lr=0.1, epochs=300, seed=20240501): rng = np.random.default_rng(seed) n, d = X.shape Y = one_hot(y) # He 初始化:适合 ReLU,方差按输入维度缩放 W1 = rng.normal(0, np.sqrt(2 / d), (d, hidden)) b1 = np.zeros(hidden) W2 = rng.normal(0, np.sqrt(2 / hidden), (hidden, 10)) b2 = np.zeros(10) losses = [] for _ in range(epochs): # 前向:ReLU -> 线性 -> softmax h = np.maximum(0, X @ W1 + b1) logits = h @ W2 + b2 logits -= logits.max(axis=1, keepdims=True) # 数值稳定 prob = np.exp(logits) / np.exp(logits).sum(axis=1, keepdims=True) loss = -np.log(prob[np.arange(n), y] + 1e-12).mean() losses.append(loss) # 反向:softmax+交叉熵的梯度直接是 prob - Y dlogits = (prob - Y) / n dW2 = h.T @ dlogits db2 = dlogits.sum(axis=0) dh = dlogits @ W2.T dh[h <= 0] = 0 # ReLU 导数 dW1 = X.T @ dh db1 = dh.sum(axis=0) W1 -= lr * dW1; b1 -= lr * db1 W2 -= lr * dW2; b2 -= lr * db2 return {"W1": W1, "b1": b1, "W2": W2, "b2": b2}, losses def predict(params, X): h = np.maximum(0, X @ params["W1"] + params["b1"]) return np.argmax(h @ params["W2"] + params["b2"], axis=1)几个参数和实现细节值得单独说:hidden=64是隐藏层宽度,32 到 128 之间都合理,再大在几千样本上就开始过拟合;lr=0.1配合全批量梯度下降是合适的,如果改成分批训练,学习率要降到 0.01 左右;logits -= max是数值稳定技巧,不做的话exp会溢出成 inf,损失变 nan,这是新手看到「训练到一半 loss 突然 nan」的头号原因。dh[h <= 0] = 0是 ReLU 的导数,注意这里用的是前向的h,不是dh,写反了梯度就是错的但程序不报错,属于典型的静默失败。
输入 X 必须先归一化到 0 到 1(像素除以 255),否则前向激活值过大会让 softmax 饱和、梯度趋近于零,表现为损失几乎不动。
4.4 调参边界:学习率、隐藏层宽度与过拟合判断
三个超参的调节顺序我一般按下面的优先级:先固定hidden=64,把lr从 0.5 往下试到损失能稳定下降;再调epochs到损失曲线走平;最后加隐藏层宽度。判断过拟合看两条曲线:训练损失持续下降而验证损失在某个 epoch 之后抬头,就是过拟合,先减宽度再加早停,别急着上正则。
| 超参 | 常用区间 | 调大的后果 | 调小的后果 |
|---|---|---|---|
| 学习率 lr | 0.01 ~ 0.3 | 损失震荡甚至发散 | 收敛慢,300 轮走不平 |
| 隐藏层宽度 | 32 ~ 128 | 训练集准确率高、测试集掉 | 欠拟合,两类样本分不开 |
| 批大小 | 16 ~ 128 | 梯度噪声小但泛化可能变差 | 训练不稳定,损失抖动 |
5. 避坑排查:五条踩过的记录
5.1 每次运行准确率都不一样
现象是同一份代码连续跑三次,KNN 的交叉验证准确率差了 2 到 4 个点。原因是切分和交叉验证的随机状态没固定,train_test_split和StratifiedKFold都用了全局随机数。解决是把random_state显式传进去,同时在入口调用seed_everything,两条都做,不要只做一条。
5.2 KNN 测试集准确率 100%
抓到 100% 别高兴,先查标准化位置。在全量数据上fit标准化器再切分,测试集的统计量泄漏进了训练,小数据集上能把准确率抬 5 个点以上。第二个可能是标签列被当成了特征,load_csv里切片写错成data[:, :]就会这样。改成先切分、再只在训练集fit,同时在切分后打印一次特征维度确认。
5.3 A* 找到的路径比 BFS 长
两种原因:启发式不可采纳,或者堆里旧记录没跳过。前者常见于把曼哈顿距离的目标坐标算错,比如用了val而不是val - 1,导致系统性高估;后者是少了if cost > g[state]: continue这行,同一个状态被重复扩展。排查方法是先用weight=1跑一个可解状态,和 BFS 步数对比,不相等就回头查启发函数。
5.4 BP 的损失从第一轮就是 nan
先看logits有没有做减最大值,再看输入有没有归一化。如果两者都没问题,检查学习率——lr=1.0以上在全批量下很容易第一步就把权重推到发散。还有一种隐蔽情况:标签是 1 到 10 而不是 0 到 9,one_hot里y.size建的矩阵索引越界,numpy 会静默回绕,损失看着在降但准确率永远上不去。
5.5 读本地 csv 报编码或分隔符错误
实验包里的数据文件可能是 GBK 编码或制表符分隔,np.loadtxt默认按空格和逗号解析。现象是ValueError: could not convert string to float。解决是打开文件看一眼真实分隔符,把delimiter改成\t或,,编码问题则改用np.genfromtxt(..., encoding="utf-8")。中文路径也建议统一改成英文,跨平台拷贝时少一类问题。
6. 验收:把「能跑」推到「能讲清楚」
实验做完不等于验收通过。我常用的验收方式是三张对照:搜索实验对比 BFS 与 A* 的扩展节点数和路径长度,机器学习实验对比三个模型的交叉验证均值与标准差,神经网络实验对比纯 numpy 版和框架版的最终准确率——两者差距在 1 个点以内,说明反向传播实现是对的。
| 验收项 | 合格线 | 判定方法 |
|---|---|---|
| 可复现性 | 同机连跑两次结果完全一致 | 固定种子后对比输出 |
| 最优性 | A*(weight=1) 步数等于 BFS | 回放函数断言 |
| 无泄漏 | 标准化只在训练集 fit | 代码走查 + 准确率不异常 |
| BP 正确性 | numpy 版与框架版差距小于 1% | 同一数据同一划分对比 |
有个细节值得单拎出来:做完 BP 之后,把隐藏层去掉再跑一遍,准确率应该明显下降。如果去掉隐藏层结果几乎不变,说明数据本身线性可分,或者你的反向传播根本没生效——这个消融实验花不了五分钟,却能挡掉大部分「看起来收敛了其实没学到东西」的情况。
从那以后我每次拆这类实验包,都强制先跑一遍「去隐藏层」和「固定种子重跑」两个自检,再去看准确率数字。很多看起来玄学的结果差异,问题都出在这两步之前。希望帮到你。
本文还有配套的精品资源,点击获取