Python人工智能实验包拆解:自动评测、A*与手写模型改造

发布时间:2026/10/30 2:49:53
Python人工智能实验包拆解:自动评测、A*与手写模型改造 简介面向高校人工智能课程实验与项目实践的学习者这份资料包对应2023年春季复旦大学人工智能A实验课程适合需要完成基础实验、项目作业及报告复盘的本科生或自学者参考。包内共52个文件以43个Python脚本为主体配合4份Markdown说明、3份PDF实验与项目文档以及2个压缩子包整体约2.35MB覆盖实验代码、数据集、实验报告和项目材料等层次。已有108人学习说明其在课程资料中具有一定参考价值。读者可从中获取实验环境的组织方式、分阶段任务代码、数据处理与模型实现思路以及实验报告的结构范例结合基础实验与综合项目的材料还能对照课程要求梳理完成路径观察命名实体识别等任务的组织方式并据此查漏补缺理解人工智能实验从数据准备到结果呈现的落地流程。1. 一份课程实验包的真实用法Python 人工智能实验从跑不动到改得动上个月有人把一份压缩包丢给我说这是他某学期人工智能课的实验合集七个实验目录Python 写的A* 那条路径看着没问题可自动评测脚本就是判错。我解压完第一件事不是看代码是先找自动评测脚本和评测题号因为课程实验包里最值钱的东西往往不是学生填的那几个函数而是common/底下那套判题逻辑和各种 layouts、数据集。这份包的名字里带“实验”实际内容是一整套人工智能入门算法的可运行实现搜索、博弈、约束满足、马尔可夫决策、贝叶斯分类再到用 numpy 手写 KNN 和两层神经网络。它适合两类人刚上完人工智能课、想把课上代码真正跑起来的中低年级学生和想拿一套“课本算法对照实现”当试验台的开发者。下面按我拆包、跑通、改代码的顺序讲。2. 目录即地图七个实验目录各自解决什么问题拿到一个没文档的压缩包别急着点开第一个.py就读。我一般先做两件事把顶层目录树打出来再找入口脚本和依赖清单。这两步能定住整个包的分层方式后面读哪个文件、改哪个文件就有坐标了。2.1 顶层结构common 是被低估的部分典型的目录结构长这样命名上各份实验包会有出入但分层逻辑大同小异ai-lab-a/ ├── common/ # 公共层判题器、数据结构、工具函数 │ ├── util.py # 优先队列、状态哈希、计时器 │ └── autograder.py # 自动评测入口按题号跑测试用例 ├── lab01_search/ # 搜索BFS / DFS / UCS / A* │ ├── search.py # 搜索策略实现学生要填的地方 │ ├── problem.py # 问题定义起始状态、后继、目标判定、代价 │ └── layouts/ # 迷宫、八数码等测试地图 ├── lab02_games/ # 博弈minimax alpha-beta 剪枝 ├── lab03_csp/ # 约束满足回溯 前向检验 MRV 启发 ├── lab04_mdp/ # MDP值迭代 / 策略迭代 ├── lab05_bayes/ # 朴素贝叶斯文本分类 ├── lab06_ml/ # 决策树、KNN、感知机numpy 手写版 sklearn 对照 ├── lab07_nn/ # 两层全连接网络手写前向反向 ├── requirements.txt └── run_all.py # 批量跑所有实验的脚本common/是整包的地基。util.py里的优先队列决定了搜索实验的性能上限autograder.py决定了“什么算对”。很多同学卡在“结果看着对但评测不过”根因全在这一层判题器比的是展开节点数、路径代价、探索顺序中的某一项不是肉眼看的那条路径。我拆包时一定先读判题器里的断言再看学生填的实现顺序反了会浪费半天。2.2 环境准备好先在干净虚拟环境里跑一次基线Python 人工智能实验包的依赖通常不重requirements.txt里一般是 numpy、scipy、scikit-learn、matplotlib 这几样神经网络实验有的用纯粹的 numpy有的额外挂 PyTorch。装之前先建虚拟环境原因是这类包经常和系统里已有的 numpy 版本打架尤其是 numpy 1.20 之后np.float、np.int这些别名被移除老代码一跑就AttributeError。# 1. 建独立环境避免和系统 numpy 打架 python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate # 2. 装依赖国内网络可加 -i 换镜像源 pip install -r requirements.txt # 3. 先跑一个最小实验确认链路通别一上来就跑全部 python common/autograder.py -q q1-q q1是题号参数含义是“只跑第一题的全部测试用例”。这个参数非常关键整包跑一次可能几分钟单题跑通常几秒调代码时应该一直用单题模式。autograder.py输出里会分三栏——用例名、得分、失败原因。失败原因那一栏才是真正要读的它通常会告诉你“expected 5 nodes expanded, got 7”比你自己加 print 高效得多。还有一个入口是run_all.py批量跑会汇总每个实验的通过情况。我的习惯是环境装完先跑一次run_all.py存个基线记录哪些实验本来就不过尤其那些标着“可选”“加分”的题目避免后面对着自己没动过的代码怀疑人生。2.3 问题抽象层problem.py 决定了算法怎么写搜索实验的核心抽象放在problem.py里一般有四个方法起始状态、判断目标、取后继、取单步代价。所有搜索算法都只依赖这四个方法这就是为什么同一份search.py能同时跑迷宫和八数码——换的是 problem 子类不是算法。# problem.py 的典型接口各实验包命名会有出入 class SearchProblem: def get_start_state(self): raise NotImplementedError def is_goal(self, state): raise NotImplementedError def get_successors(self, state): 返回 [(后继状态, 单步代价), ...]状态必须可哈希 raise NotImplementedError读这三个接口要注意两个隐含约束。第一状态必须可哈希因为搜索算法内部会拿状态做visited集合的 key如果状态写成 list 就会TypeError: unhashable type得改成 tuple。第二get_successors要返回带代价的二元组而不是裸状态不然 UCS 和 A* 没法比较路径优劣BFS 能跑但一换算法就崩。这两条是我见过最高频的“改一行就能过”的问题。3. 搜索与博弈模块A* 的启发式函数怎么写才不翻车搜索是整个包里最容易上手、也最容易在“看起来对”的地方栽跟头的部分。BFS、DFS、UCS 三者代码骨架几乎一致差别只在边界frontier用什么数据结构A* 在这之上多一个启发式函数而启发式一旦不满足可采纳性返回的路径就不再最优但肉眼看依然“像条路”。3.1 一份能直接跑的 A* 实现下面这版是我在课程包基础上整理过的写法重点在两处细节counter和best_g。import heapq def a_star_search(problem, heuristic): problem: 实现 get_start_state / is_goal / get_successors 的问题对象 heuristic: (state, problem) - 估计代价必须满足可采纳性 返回: (路径列表, 展开节点数) start problem.get_start_state() counter 0 # 打破 f 值相同时的比较 # 队列元素: (f, g, 序号, 状态, 路径) frontier [(heuristic(start, problem), 0, counter, start, [start])] best_g {start: 0} # 记录已知最优 g剪掉重复扩展 expanded 0 while frontier: f, g, _, state, path heapq.heappop(frontier) if problem.is_goal(state): return path, expanded # 惰性删除堆里可能残留旧的劣质条目出堆时跳过 if g best_g.get(state, float(inf)): continue expanded 1 for nxt, step_cost in problem.get_successors(state): new_g g step_cost if new_g best_g.get(nxt, float(inf)): best_g[nxt] new_g counter 1 heapq.heappush( frontier, (new_g heuristic(nxt, problem), new_g, counter, nxt, path [nxt]) ) return None, expanded逻辑说明逐段过一遍。frontier里存五元组第一项是 f g h决定出堆顺序第二项 g 单独留一份是为了出堆后做惰性删除第三项counter是必需的因为 Python 的heapq在比较元组时如果前两项相同会继续比第三项而状态对象比如一个 tuple 坐标之间不保证可比较某些情况下会直接抛TypeError: not supported。这是个很玄学的报错明明算法逻辑没错却崩在标准库里根因就是这个。加个自增计数器成本极低直接绕开。best_g是第二处关键。A* 在启发式一致时不会重复扩展同一状态但一旦启发式只是“可采纳但不一致”同一状态可能以不同 g 被多次入堆。出堆时比对g best_g[state]就能把这些废条目丢掉省下的展开节点数在八数码这种状态空间下是数量级的差别。参数heuristic签名固定成(state, problem)是为了让迷宫用曼哈顿距离、八数码用错位数时能共用同一份搜索代码。3.2 启发式函数可采纳性是硬约束迷宫场景的曼哈顿距离、八数码的错位块数这两个是课程里默认的答案但它们的可采纳性各自依赖一个前提。曼哈顿距离只有在“只能上下左右四向移动”时可采纳如果地图允许八向移动就得换成切比雪夫距离否则 A* 返回的路径会比最优长评测脚本一比对代价就判错而你盯着路径图看半天看不出毛病。八数码的错位块数天然可采纳但如果用“所有块到目标位置的曼哈顿距离之和”在允许任意块移动到空位时同样可采纳值更紧展开节点更少。def manhattan(state, problem): 四向移动迷宫用|dx| |dy|可采纳 x1, y1 state x2, y2 problem.goal return abs(x1 - x2) abs(y1 - y2) def misplaced(state, problem): 八数码用错位块数每块至少要挪一次故可采纳 return sum(1 for a, b in zip(state, problem.goal) if a ! 0 and a ! b)参数说明state是可哈希的状态表示迷宫用坐标元组八数码用长度 9 的平坦元组0 代表空位所以要排除掉problem传进来是为了拿目标状态。写启发式时脑子里要有一句话h(n) 永远不能超过从 n 到目标的真实最小代价。越接近真实代价A* 展开的节点越少但一旦超过就不再最优。这个“越紧越好但不能越界”的边界感是搜索实验真正想训练的东西。3.3 博弈模块alpha-beta 剪枝的两个经典 bug博弈实验一般是让两个智能体在一个小棋盘上对弈核心是 minimax 加 alpha-beta 剪枝。递归骨架如下def alpha_beta(state, depth, alpha, beta, maximizing, get_actions, apply_action, is_terminal, evaluate): alpha: 当前 MAX 节点已知下界 beta: 当前 MIN 节点已知上界 返回: 该状态对 MAX 的评估值 if depth 0 or is_terminal(state): return evaluate(state) if maximizing: value float(-inf) for action in get_actions(state): child apply_action(state, action) value max(value, alpha_beta(child, depth - 1, alpha, beta, False, get_actions, apply_action, is_terminal, evaluate)) alpha max(alpha, value) if alpha beta: # 剪枝MIN 不会选这条路 break return value else: value float(inf) for action in get_actions(state): child apply_action(state, action) value min(value, alpha_beta(child, depth - 1, alpha, beta, True, get_actions, apply_action, is_terminal, evaluate)) beta min(beta, value) if beta alpha: break return value两个经典 bug 都出在剪枝条件上。第一个是把alpha beta写成alpha beta逻辑上看着也能“剪掉一些”但评测通常会比对访问过的节点数少剪一次就判不过。第二个更隐蔽剪枝 break 之后没有把 alpha/beta 正确回传导致父节点的边界还是旧值搜索树的一部分被错误剪掉最终落子明显变差而代码语法完全正确。验证办法很土但有效——把剪枝条件临时改成永不成立直接删掉 if 那两行跑同一局面剪枝版和暴力版的最优值必须一致不一致就是剪错了。这个对照测试我每次改博弈代码都会跑一遍。调试时还有个实用参数是深度。深度设 2 和设 4 的结果必须不同如果一样多半是apply_action没有真正推进状态或者get_actions返回了空列表直接落到终端判定递归根本没展开。4. 手写模型模块numpy 从零实现 KNN 与两层神经网络课程包的机器学习部分通常是两条线并行一条用 sklearn 几行搞定作为效果基线一条用 numpy 手写作为理解训练过程的练习。两条线的输出要能对上对不上说明手写版有 bug这个对照思路非常好用。4.1 KNN用广播代替双层循环import numpy as np class KNNClassifier: def __init__(self, k5, p2): self.k k self.p p # p2 欧氏距离p1 曼哈顿距离 def fit(self, X, y): self.X np.asarray(X, dtypenp.float64) # (n_train, d) self.y np.asarray(y).astype(np.int64) # 标签须为非负整数 return self def predict(self, X): X np.asarray(X, dtypenp.float64) # (n_test, d) # 广播成 (n_test, n_train, d)一次算完所有距离 diff X[:, None, :] - self.X[None, :, :] dist np.power(np.sum(np.abs(diff) ** self.p, axis2), 1.0 / self.p) idx np.argsort(dist, axis1)[:, :self.k] # 每行取最近的 k 个 labels self.y[idx] # (n_test, k) return np.array([np.bincount(row).argmax() for row in labels])逻辑说明X[:, None, :]和self.X[None, :, :]各补一个维度让两个矩阵在第三维上做差一步得到全部测试样本到全部训练样本的距离矩阵形状(n_test, n_train)。这比写两层 for 循环快一到两个数量级而且代码更短。参数k直接决定决策边界的平滑程度k1 时对噪声极其敏感k 取到训练集规模就是“永远预测众数”。p参数保留着是为了做对照实验p1 在稀疏高维特征上有时更稳。np.bincount是这里的一个小前提标签必须是非负整数。如果数据集里的标签是 -1/1 或者字符串bincount会直接报错。遇到这种情况先做一次标签编码把它映射到 0..C-1预测完再映射回去。这个坑很常见因为 sklearn 的接口对标签类型宽容得多同一份数据喂给 sklearn 不报错喂给自己写的就崩。4.2 两层网络softmax 交叉熵 手写反向传播class TwoLayerNet: def __init__(self, n_in, n_hidden, n_out, seed0): rng np.random.default_rng(seed) # 固定种子保证可复现 # He 初始化适配 ReLU self.W1 rng.normal(0, np.sqrt(2.0 / n_in), size(n_in, n_hidden)) self.b1 np.zeros(n_hidden) self.W2 rng.normal(0, np.sqrt(2.0 / n_hidden), size(n_hidden, n_out)) 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.0, self.z1) # ReLU self.logits self.a1 self.W2 self.b2 # 减去每行最大值防止 exp 溢出成 inf e np.exp(self.logits - self.logits.max(axis1, keepdimsTrue)) self.probs e / e.sum(axis1, keepdimsTrue) return self.probs def backward(self, y, lr1e-2): n y.shape[0] dlogits self.probs.copy() dlogits[np.arange(n), y] - 1.0 # softmaxCE 的梯度就是 p - onehot dlogits / n # 按 batch 取平均 dW2 self.a1.T dlogits db2 dlogits.sum(axis0) da1 dlogits self.W2.T dz1 da1 * (self.z1 0) # ReLU 的导数注意在 0 处取 0 dW1 self.X.T dz1 db1 dz1.sum(axis0) for param, grad in ((self.W2, dW2), (self.b2, db2), (self.W1, dW1), (self.b1, db1)): param - lr * grad # 原地更新省内存参数说明n_in是特征维度n_hidden是隐藏层宽度n_out是类别数lr是学习率一般从 1e-2 起调损失震荡就往下压一个数量级。seed固定下来很重要否则每次跑出来的准确率都不同调参时根本分不清是改了超参还是随机性作祟。逻辑上有三处值得盯。第一处是 softmax 前减最大值这个操作数学上不改变结果工程上是保命措施logits 稍大一点np.exp就溢出成inf接着整行变成nan训练直接发散报错信息里只有一句“loss is nan”非常黑匣子。第二处是dlogits直接复用probs再减 1这是 softmax 与交叉熵合并求导后的简化形式比先求 softmax 导数再乘交叉熵导数省一大截计算。第三处是 ReLU 导数用self.z1 0做布尔掩码正好让 0 处的导数取 0不用再单独处理。验证手写版是否正确最省事的办法是和 sklearn 的MLPClassifier在同一份数据上比准确率差 2 个百分点以内基本没问题差得多先检查是不是忘了除 batch size这个失误会让梯度大 n 倍表现是损失剧烈震荡然后卡住。5. 排查清单实验代码跑不通的六种典型现象课程实验包最耗时的环节从来不是写算法是跑不通。下面五条是我这两年反复遇到的现象按“现象 → 原因 → 解决”整理前两条偏环境后三条偏算法与数据。5.1 环境与依赖类现象一ModuleNotFoundError: No module named common但目录明明在。原因几乎总是运行位置不对。autograder.py内部用的是from common.util import ...这类绝对导入它默认你从项目根目录启动。你在lab01_search/里敲python search.pyPython 的模块搜索路径里只有当前目录自然找不到上一级的common。解决办法有两个选一个即可回到根目录用python -m lab01_search.search的方式跑-m会把当前目录加进sys.path或者在入口文件顶部手动把根目录塞进路径。后者更省事但要注意写在所有项目内导入之前import sys, os # 把项目根目录插到 sys.path 最前面兼容从任意子目录启动 sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))现象二AttributeError: module numpy has no attribute float。原因是这份代码写在更早的 numpy 版本上用了np.float、np.int、np.bool这些内置类型的别名1.20 之后它们被移除1.24 之后彻底不可用。系统 Python 里的 numpy 版本往往比虚拟环境里的新所以同一个文件换个环境跑结果不一样。解决思路是别降级 numpy 去迁就老代码而是改代码——把np.float换成float或np.float64np.int换成int或np.int64np.bool换成bool。全包搜一遍改完通常不超过十个位置。顺带检查np.object现在应该写object。这类改动没有副作用也不会影响数值精度。5.2 算法与数据类现象三A能返回路径但路径代价比评测期望值大。*先怀疑启发式不可采纳而不是算法框架。最常见的具体情形是地图允许八向移动却用了曼哈顿距离h 会高估真实代价A* 就退化成贪心拿到的是一条更长的路。验证方法很直接把heuristic换成恒返回 0 的函数这时 A* 等价于 UCS结果一定是真正的最优解。如果零启发式能过、原启发式不能过问题就锁定在启发式上换切比雪夫距离或把八向移动改成四向即可。还有个小概率情况是get_successors里的单步代价写成了固定值 1而地图本身带权重格。这种 bug 的表现是所有路径代价都偏小且与地图上的权重点数量相关。现象四KNN 分类准确率只有 0.1 左右接近随机猜。先看数据形状再看标签。特征矩阵如果被误转置成(d, n)距离计算不会报错只会算出一堆无意义的数结果就接近随机。加一行assert X.shape[1] self.X.shape[1]能立刻抓住。另一种情况是标签本身是字符串或负数前面提过np.bincount会直接抛异常但如果某处做了隐式类型转换可能悄悄变成全 0预测结果就退化成常数。排查顺序建议固定成打印X.shape、y的取值集合、前五个预测值和前五个真实值。这四行输出基本能定位九成数据问题。现象五神经网络训练时 loss 打印出nan。九成是数值溢出。logits 里出现较大数值时np.exp溢出反向传播再一乘整个参数矩阵变成nan并污染后续所有迭代。前面forward里减去行最大值就是防这个。如果已经减了还是nan就往两个方向查学习率太大导致参数一步跨飞把lr从 1e-2 降到 1e-3 试一次或者输入特征没有归一化某列数值在千级别第一层输出直接撑爆。特征标准化这一步在课程包里经常是留空的得自己补。另一个隐蔽来源是dz1 da1 * (self.z1 0)这一步如果da1里已经有nan掩码也救不回来。所以定位nan要在每次backward之后检查四个参数矩阵找出第一个出现nan的那一层问题就在那一层的梯度计算里。现象六同一份代码两次运行准确率差好几个百分点。随机种子没固定。np.random.default_rng(seed)这种显式传种子的写法能解决大部分场景但 sklearn 的模型要单独传random_state数据划分的train_test_split也要传。课程包里如果学生填的部分是随机初始化而外层又没统一设种子复现性就没了。调参前先把所有随机源钉死否则你永远在跟噪声搏斗。6. 二次开发把课程实验改造成自己的算法试验台这套代码最值得改的地方不是算法本身是把它从“为评测而生”改成“为自己实验而生”。我给自己的改造顺序是这样的。第一步抽公共接口。七个实验各有一套 problem 定义把“状态、动作、代价、目标”这四个概念统一成一个Environment协议搜索、博弈、MDP 三部分就能共用同一份环境实现。改动量不大主要是加一层适配但后面换题目时不用再复制粘贴。第二步补可视化。搜索实验加一个 matplotlib 的网格热力图把展开过的节点按顺序染成渐变色一眼就能看出启发式到底有没有引导搜索朝目标走。这一步的收益远超预期——很多“数值上过、思路不对”的实现一画图就露馅。第三步加基准测试。用一个简单的time.perf_counter包住主循环记录展开节点数、耗时、峰值内存输出成表格存 CSV。同一份算法在曼哈顿、错位数、零启发式三种设定下跑一遍对比数据比任何讲解都直观。改造点工作量直接收益统一 Environment 协议中三个实验共用一套题目定义搜索过程可视化小启发式是否有效一眼可判基准测试脚本小调参有数据依据不靠感觉KNN 换向量化距离库小大数据集上快一个数量级验证改造有没有改坏靠的还是原包里的autograder.py。我的习惯是每改一处就跑一次单题评测全绿再继续。别攒着改完再跑中间任何一步引入的回归都会淹没在一堆报错里。有次我把 KNN 的距离计算换成矩阵乘法形式(X**2).sum(1)[:,None] - 2*XZ.T (Z**2).sum(1)速度确实快了但忘了在开方前把负数截断到 0浮点误差导致对角线出现-1e-16np.sqrt直接给nan整个预测全是 0 类。这个 bug 藏了两天才揪出来因为它在小数据集上不出现只有样本量上去之后浮点误差才累积到能触发。从那以后我每次动距离计算都强制先跑一遍“自己到自己距离必须为 0”的断言再跑分类评测——这个习惯帮我省掉的调试时间比任何优化技巧都多。希望这套拆法帮到你。本文还有配套的精品资源点击获取