简介:这份2023年春季复旦大学人工智能A实验压缩包,面向正在学习人工智能、机器学习课程并需要完成实验与项目作业的高校学生,也可供自学者复现课程案例。包内共52个文件,以43个Python脚本为核心,辅以Markdown说明、PDF实验报告与项目文档,并含数据集压缩包,整体约2.35MB,便于按实验模块查阅代码、报告与数据。内容围绕lab1、lab2及Project展开,涉及数据处理、模型实现、命名实体识别等实践环节,既有可运行脚本,也有实验报告与项目说明,可帮助读者理解实验流程、对照实现思路、复盘结果并补充排错线索。已有108人学习,适合作为课程实验参考与代码练习材料。
1. 一套课程实验包的用法:别当答案,当一套可复现的 Python 人工智能流水线
期末整理硬盘的时候,翻出一个某高校人工智能 A 课程的实验压缩包,解压出来是十几个 Python 脚本、几份 Notebook 和一堆数据目录。大多数人拿到这种资源的第一反应是找答案,但对已经能写脚本的人,它真正的价值在于把课上的每个知识点都变成了一段能跑起来的代码:从图搜索到特征工程,再到一个小型神经网络的训练循环,全是同一套工程习惯串起来的。这套东西适合正在学 Python 与人工智能、想要一份完整参考实现的人,也适合带实验课的人拿去当对照样本。下面按解压、配环境、跑通、改造的顺序,把它逐层拆开来看。
2. 解压后的第一件事:目录结构、技术栈与运行环境
2.1 顶层目录的划分逻辑
这类实验包常见的组织方式是「一个实验一个目录」,目录名带编号和主题词,公共逻辑沉到单独的 common 包里。别急着打开某个 .py 文件,先看清整个目录的边界,后面配环境和排错全靠这张地图。
ai-course-labs/ ├── lab01_search/ # 图搜索与启发式搜索 │ ├── search_core.py # BFS / DFS / A* 的通用实现 │ ├── run_experiments.py # 批量跑地图、输出路径长度与扩展节点数 │ └── maps/ # 迷宫、地图的纯文本描述文件 ├── lab02_ml_basics/ # 传统机器学习完整流程 │ ├── features.py # 特征构造与清洗 │ ├── train_eval.py # 训练、交叉验证、评估报告 │ └── data/ # 表格型数据集 ├── lab03_nn/ # 神经网络 │ ├── dataset.py # Dataset 与 DataLoader 封装 │ ├── model.py # 网络结构定义 │ ├── train.py # 训练循环与验证 │ └── configs/ # YAML 配置:学习率、轮数、批大小 ├── lab04_notebook/ # 课堂演示用 Notebook ├── common/ # 日志、随机种子、路径工具 ├── requirements.txt └── README.md这个划分背后有一条清晰的线索:lab01 是纯算法,不依赖任何第三方数值库,用标准库的heapq和collections就能跑;lab02 面向表格数据,走的是 numpy + pandas + scikit-learn 这条最稳的路;lab03 才引入深度学习框架,并且特意把数据管线、模型定义、训练循环拆成三个文件。拆成三个文件这件事值得学,很多课程代码喜欢把所有东西塞进一个main.py,改一个超参就要翻两百行,改到后面自己都晕。common 目录则是在解决一个很实际的问题——路径、日志、随机种子这三样东西如果每个实验各写一遍,最后必然不一致。
我一般会先看run_experiments.py这类入口脚本,而不是核心算法文件。入口脚本决定了一个实验怎么被调用、参数从哪来、结果写到哪里去,看懂入口,整套代码的调用关系就顺下来了。
2.2 技术栈分层:哪些库负责哪一段流程
把依赖按「承担哪一段工作」来分类,比只列一个 requirements.txt 有用得多,因为出问题的时候你能立刻定位到是哪一层的事。
| 目录/模块 | 主要依赖 | 承担的工作 | 出问题时的典型表现 |
|---|---|---|---|
| lab01_search | 标准库 heapq、collections、itertools | 优先队列、状态去重、路径回溯 | 结果不是最优解,或运行时间暴涨 |
| lab02_ml_basics | numpy、pandas、scikit-learn | 特征表构建、模型训练、交叉验证 | 维度不匹配、类别比例失衡、指标异常 |
| lab03_nn | torch、torchvision、tqdm | 数据加载、前向反向、指标统计 | 损失不下降、显存溢出、训练/验证行为不一致 |
| common | PyYAML、logging | 配置读取、路径解析、日志与种子 | 找不到文件、结果不可复现 |
| lab04_notebook | jupyterlab、matplotlib | 课堂演示、可视化 | 内核选错、图表不显示、中文乱码 |
这套分层里最容易被忽略的是 common。表面上它只是几个工具函数,实际上它决定了整套实验能不能复现。随机种子、路径解析、日志格式这三件事一旦散落在各个实验里,跑出来的结果对不上,你连从哪查起都不知道。
另外要留意 scikit-learn 和 torch 的定位差别:前者是「把模型当组件拼进 Pipeline」,后者是「自己写训练循环」。两个实验的设计意图不同,不要试图把 lab02 的写法直接搬到 lab03,那样只会更乱。这也是这个包值得看的地方——它用两种形态展示了两类工程习惯。
2.3 环境搭建顺序:虚拟环境、依赖、内核注册
环境这一步最容易出问题,关键在于「先隔离、再装依赖、最后注册内核」这三步的先后顺序。跳过任一步,后面都会遇到一些难以理解的问题。
# 1. 建虚拟环境,Python 版本按 README 说明来,一般 3.9 以上即可 python -m venv .venv # 2. 激活环境:macOS / Linux 用 source,Windows 用 Scripts 下的 activate source .venv/bin/activate # 3. 升级 pip 再装依赖,老版本 pip 解析依赖时会报一些奇怪的冲突 python -m pip install --upgrade pip pip install -r requirements.txt # 4. 把当前虚拟环境注册成 Jupyter 内核,Notebook 才能 import 到刚装的包 python -m ipykernel install --user --name ai-labs --display-name "Python (ai-labs)"几个参数的含义值得说清楚:--user表示内核配置写到当前用户目录,不需要管理员权限;--name是内核的内部标识,Notebook 顶部的内核切换菜单里能看到;--display-name是给人看的名字,建议起个能区分的,比如带上实验包的名字。第 4 步不做的话,Notebook 里import torch会直接抛ModuleNotFoundError,而终端里明明装好了——这是新手最常遇到的一类问题,本质是 Notebook 用的内核和终端用的解释器不是同一个。
装依赖时还有一个细节:深度学习框架默认可能拉取体积很大的构建包,如果机器没有独立显卡,建议在装之前先确认 requirements.txt 里有没有 CPU 版本的相关说明。没有的话,按官方索引单独装一次 CPU 版本,再装剩下的依赖,能省掉几十分钟的下载和一堆驱动相关的报错。
3. 核心实验拆解:搜索算法、特征工程与训练循环三条主线
3.1 图搜索的通用骨架:状态、动作与启发函数
lab01 里最值得看的是搜索骨架的抽象方式。它没有为每种搜索算法各写一遍循环,而是把「邻居怎么生成」抽成一个函数参数,然后 BFS、DFS、A* 共用同一套状态管理。这个设计思想放在任何涉及状态空间的问题上都适用。
# lab01_search/search_core.py —— 可复用的搜索骨架 from collections import deque import heapq def bfs(start, goal, neighbors): """广度优先:只保证步数最少,不看边的代价。状态必须可哈希。""" queue = deque([(start, [start])]) seen = {start} while queue: state, path = queue.popleft() if state == goal: return path for nxt, _cost in neighbors(state): if nxt not in seen: seen.add(nxt) queue.append((nxt, path + [nxt])) return None def astar(start, goal, neighbors, heuristic): """A*:heuristic 需要满足可采纳性,否则找到的未必是最优解。""" # 堆里存 (f, g, state, path),f = g + h,g 放第二位用于同分时优先扩展浅层 open_heap = [(heuristic(start), 0, start, [start])] best_g = {start: 0} while open_heap: f, g, state, path = heapq.heappop(open_heap) if state == goal: return path, g for nxt, step_cost in neighbors(state): new_g = g + step_cost if new_g < best_g.get(nxt, float("inf")): best_g[nxt] = new_g heapq.heappush( open_heap, (new_g + heuristic(nxt), new_g, nxt, path + [nxt]), ) return None, float("inf")先看参数:start和goal是可哈希的状态对象,迷宫里可以是坐标元组,滑块类问题里可以是一个把二维网格压平后的元组;neighbors(state)返回一个(下一状态, 代价)的序列,代价在 BFS 里被忽略,在 A* 里参与累加;heuristic(state)返回当前状态到目标的估计值,常见的写法是曼哈顿距离或错位块数。
逻辑上,BFS 用seen做入队即标记的去重,A* 用best_g记录每个状态已知的最小代价,只有当新路径更便宜时才重新入堆。这个差别决定了 A* 能否在带权图上找到最优解:如果只用seen做去重,一个状态第一次出堆就锁死,后面更优的路径就被丢掉了。这也是很多人在这个实验里遇到「明明写了 A*,路径却比 BFS 还长」的根本原因。
代码里path + [nxt]这种写法在状态空间大时会带来明显的内存开销,每个节点都复制一次完整路径。熟悉之后可以改成只记录 parent 指针,最后从目标回溯,内存占用会降一个量级。什么时候改?扩展到几十万节点以上再改,小规模迷宫里没必要提前优化。
3.2 传统机器学习实验:从特征表到评估报告
lab02 展示的是一个完整的表格型建模流程,它的价值在于「每一步都能单独替换」。整个流程被塞进 Pipeline,标准化和模型绑在一起,交叉验证时就不会出现「先用全量数据 fit 标准化器」这种数据泄漏。
# lab02_ml_basics/train_eval.py —— 一次实验的完整闭环 import pandas as pd from sklearn.model_selection import train_test_split, cross_val_score from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import classification_report def build_pipeline(n_estimators=200, max_depth=None, seed=42): # 标准化对树模型意义不大,保留是为了方便一行换成逻辑回归做对比 return Pipeline([ ("scaler", StandardScaler()), ("clf", RandomForestClassifier( n_estimators=n_estimators, max_depth=max_depth, random_state=seed, n_jobs=-1, )), ]) def run(csv_path, target_col, seed=42): df = pd.read_csv(csv_path) X = df.drop(columns=[target_col]).values y = df[target_col].values # stratify=y 保证训练集和测试集的类别比例与原始数据一致 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=seed, stratify=y ) model = build_pipeline(seed=seed).fit(X_train, y_train) print(classification_report(y_test, model.predict(X_test), digits=4)) # 交叉验证复用同一套 Pipeline,标准化只在训练折上 fit cv = cross_val_score(build_pipeline(seed=seed), X, y, cv=5) print(f"5 折均值 {cv.mean():.4f} 标准差 {cv.std():.4f}") return model参数上,test_size=0.2是课程实验里最常见的划分比例,样本量大可以压到 0.1;stratify=y在类别不均衡时必开,不开的话可能出现某一类在测试集里一个样本都没有的情况,评估报告里直接显示 undefined;n_jobs=-1表示用满所有 CPU 核心,树模型并行训练几乎线性加速。
这里有一个容易被忽略的点:classification_report输出的是每个类别的精确率、召回率和 F1,而不是一个总分。如果只看 accuracy,遇到类别不均衡的数据集就会误判——多数类占九成时,全猜多数类也能拿到 0.9 的准确率。这个实验包里同时打印分类报告和交叉验证结果,就是在提醒这件事。
cross_val_score返回的是一组分数而不是单个数字,标准差同样重要。均值高但标准差很大的模型,说明它对数据划分极其敏感,换一批数据就可能不稳定。我一般会要求标准差控制在 0.03 以内,超过就去检查特征是否有异常值或样本量是否太少。
3.3 神经网络实验:Dataset、DataLoader 与训练循环
lab03 的训练循环写得比较朴素,但训练、评估这两个函数的分工很清楚,该有的train()/eval()切换和no_grad一个不少。这套骨架基本可以直接搬到自己的小项目里。
# lab03_nn/train.py —— 训练与验证的最小闭环 import torch def train_one_epoch(model, loader, optimizer, criterion, device): model.train() # 影响 BatchNorm 与 Dropout 的行为,漏掉会让结果整体偏 total_loss, correct, total = 0.0, 0, 0 for x, y in loader: x, y = x.to(device), y.to(device) optimizer.zero_grad() logits = model(x) loss = criterion(logits, y) loss.backward() optimizer.step() # 乘 batch 大小再累加,最后除总样本数,避免最后一个不满批的 batch 拉偏均值 total_loss += loss.item() * x.size(0) correct += (logits.argmax(dim=1) == y).sum().item() total += x.size(0) return total_loss / total, correct / total @torch.no_grad() # 验证阶段不建计算图,显存占用能降一大截 def evaluate(model, loader, criterion, device): model.eval() total_loss, correct, total = 0.0, 0, 0 for x, y in loader: x, y = x.to(device), y.to(device) logits = model(x) total_loss += criterion(logits, y).item() * x.size(0) correct += (logits.argmax(dim=1) == y).sum().item() total += x.size(0) return total_loss / total, correct / total核心参数有三个:device决定张量在哪块硬件上,写torch.device("cuda" if torch.cuda.is_available() else "cpu")就能同时兼容两种环境;criterion分类任务用交叉熵,注意交叉熵内部已经含 softmax,模型最后一层不能再加一次;optimizer.zero_grad()必须在backward()之前调用,位置放错会让梯度在 batch 之间累积,损失曲线变成一条乱跳的折线。
loss.item() * x.size(0)这个写法值得单独说。很多人直接累加loss.item()然后除以 batch 数量,当数据集大小不能被 batch 整除时,最后一个 batch 样本更少但权重相同,算出来的平均损失会轻微偏移。实验报告里对不上数,经常就是这个原因。
还有一个常见做法是在训练循环里加一个学习率调度器,按 epoch 或按验证指标衰减。课程实验一般不用,但自己改的时候加上往往能提升几个点,代价是要多调一个超参。
3.4 Notebook 与模块的分工边界
lab04 的 Notebook 只做两件事:调参演示和结果可视化。所有能复用的逻辑都在 lab01 到 lab03 的模块里,Notebook 靠from lab03_nn.model import Net这种方式引用。这个边界划得很关键。
反过来做——把训练逻辑全写进 Notebook——短期看着方便,改两次之后就很难维护了:单元格执行顺序混乱、变量残留、换个数据集要复制一整个 Notebook。我自己踩过的坑是同一个 Notebook 里改了模型结构但没重启内核,旧权重还在内存里,跑出来的准确率怎么调都对不上,查了大半天。
判断标准很简单:一段代码如果在两个 Notebook 里都出现过,就该抽成模块;一段代码如果只跑一次看看结果,留在 Notebook 里没关系。这个实验包的划分基本符合这条线。
4. 跑通实验的实操路径:依赖、数据落位与结果校验
4.1 依赖安装与版本对齐
课程实验包的 requirements.txt 往往写得很宽松,直接pip install -r有可能装到和课程环境差异较大的版本。更稳的做法是先看一眼内容,把关键库的版本范围收紧。
# requirements.txt 的建议写法:给下限,同时给一个已知可用的上限 numpy>=1.24,<2.0 # 2.x 之后部分旧接口有变动,按 1.x 写的代码可能报错 pandas>=2.0 scikit-learn>=1.3 matplotlib>=3.7 torch>=2.0 jupyterlab>=4.0 tqdm PyYAML上限不是必须的,但在教学场景里很有用:半年后重新装环境,没有上限的包可能已经改了几个大版本,旧代码报的错会让人误以为是自己的问题。装完之后执行pip freeze > env-lock.txt留一份快照,下次复现直接照着快照装,能省掉大量排查时间。
如果安装过程中看到某个包在编译 C 扩展,多半是预编译轮子没匹配上当前平台。这种情况优先升级 pip 再重试,其次是换一个稍旧的版本;实在不行再看是否需要系统级的编译工具链。别在这个时候硬扛,换版本往往是最快的路。
4.2 数据与资源文件的落位约定
这类实验包的数据文件通常不随压缩包一起分发,或者体积太大被单独放在了别处。跑之前先确认数据目录的存在性和文件命名,比跑起来再报错要省事。
# common/paths.py —— 用文件位置反推项目根,避免写死绝对路径 from pathlib import Path ROOT = Path(__file__).resolve().parents[1] DATA = ROOT / "data" def check_layout(required): """启动时统一检查必需文件,缺什么一次性报出来。""" missing = [p for p in required if not (ROOT / p).exists()] if missing: raise FileNotFoundError("缺少以下文件或目录:" + "、".join(missing))用Path(__file__).resolve().parents[1]定位根目录的好处是,不管从哪个工作目录启动脚本,路径都能算对。直接用open("data/xxx.csv")这种相对路径,在项目根目录跑没问题,一旦cd进子目录或者从 Notebook 里调用,就会抛FileNotFoundError——这是最容易让人误判成「数据没下载」的一类问题。
check_layout这个函数看着不起眼,实际很省事:把所有必需文件列进去,启动时一次性检查完,缺三个文件就一次报三个,而不是跑一段报一个。我习惯在每个入口脚本的第一行就调用它。
4.3 三层校验:随机种子、指标数值、可视化产物
跑通不等于跑对。我的习惯是用三层校验来判断一次实验是否真的复现了:种子固定后结果是否稳定、指标是否落在合理区间、可视化产物是否符合直觉。
# common/seed.py —— 一次把所有随机源都固定住 import os import random import numpy as np def set_seed(seed: int = 42): os.environ["PYTHONHASHSEED"] = str(seed) random.seed(seed) np.random.seed(seed) 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 之后尽快调用,越早越好。PYTHONHASHSEED影响哈希相关的遍历顺序,集合和字典的迭代顺序变化会让「去重后的样本顺序」发生变化,进而影响结果。cudnn.deterministic = True会关掉一部分自动调优的卷积算法,速度略降但结果可复现,做实验报告时值得开。
指标校验的标准因任务而异。分类任务看准确率和 F1 是否在同一量级,如果 F1 明显低于准确率,大概率是类别不均衡;回归任务看 RMSE 是否远大于目标值的标准差,是的话说明模型基本没学到东西。可视化则是最直观的一层:搜索算法的路径画出来有没有穿墙、混淆矩阵是不是只亮对角线,一眼就能看出问题。
三层都过,才算真的跑通。只跑不校验,等于把实验跑成了随机数生成器。
5. 排查清单:跑不起来、结果对不上、内核崩溃的常见原因
5.1 环境类问题
现象:Notebook 里import torch报 ModuleNotFoundError,终端里同样的命令却正常。原因:Notebook 绑定的内核和虚拟环境不是同一个,Jupyter 默认用的可能是系统 Python。解决:python -m ipykernel install --user --name ai-labs注册当前环境的内核,然后在 Notebook 右上角手动切到Python (ai-labs),重启内核再跑。切完可以用import sys; print(sys.executable)确认路径指向虚拟环境。
现象:安装过程卡在某个包上十几分钟不动,或者报编译错误。原因:当前平台没有匹配的预编译轮子,pip 退回到了源码编译,而系统缺少编译工具链;或者网络拉取的是体积很大的 GPU 构建包。解决:先pip install --upgrade pip再重试,仍失败就指定稍旧的版本,或者按官方索引单独安装;无独立显卡的机器优先装 CPU 版本。
现象:plt.show()之后图表没弹出来,或者中文全部显示成方块。原因:无图形界面的环境(比如远程连接、容器内)没有可用的绘图后端;中文乱码则是字体没有配置。解决:无界面环境改用matplotlib.use("Agg")并把图保存成文件;中文乱码加两行字体配置,指定一个系统里存在的中文字体,并把axes.unicode_minus设为 False 解决负号显示问题。
5.2 代码与数据类问题
现象:每次运行交叉验证的分数都不一样,实验报告里写的数值复现不出来。原因:只设了 numpy 的种子,没设 Python 的random和框架内部的种子;或者 DataLoader 开了多进程并且 shuffle 没绑 generator。解决:统一调用前面那个set_seed,DataLoader 里传入generator=torch.Generator().manual_seed(seed),同时把num_workers在调试阶段设为 0,确认结果稳定后再开多进程。
现象:A跑出来的路径比 BFS 还长,或者明明有解却返回 None。* 原因:前者多半是启发函数不可采纳,高估了实际代价;后者常见于状态去重时机不对,把尚未探索完的等价状态提前锁死。解决:先把启发函数换成恒返回 0 的版本,退化成 Dijkstra 看结果是否正确,确认搜索框架没问题后再逐步加回启发项;去重改为记录最小代价而非简单的 seen 集合。另外状态对象如果用列表这种不可哈希的类型,入堆时还会报 TypeError,统一转成元组。
现象:训练损失在前几个 epoch 剧烈震荡,准确率一直是随机水平。原因:学习率过大、输入没有归一化、zero_grad()位置放错导致梯度累积,或者model.train()和model.eval()该切换的地方没切。解决:先把学习率除以 10 试一次,同时对输入做标准化;在训练循环前后打印一次参数是否变化,快速确认梯度是否真的回传了。
这几条基本覆盖了我在同类实验包里见过的大部分问题。规律是:环境问题看内核和路径,结果问题看种子和随机源,指标问题看类别分布和学习率。按这个顺序查,比乱改代码快得多。
6. 二次开发:把这套实验包改成自己的练手底座
当所有实验都能跑通、结果也稳定之后,它对你的价值就到顶了,接下来该考虑怎么把它改成自己的东西。我一般从三个方向动手,按投入产出排序。
第一个方向是统一入口。现在每个实验都有自己的run_experiments.py,参数散落在各处。可以把入口收敛成一个命令行工具,用argparse或typer把数据集、种子、输出目录都做成参数,配置文件用 YAML 读取。这样跑对比实验时只改一个参数文件,不用翻代码。
# tools/cli.py —— 把散落的入口收成一个命令 import argparse from pathlib import Path from common.seed import set_seed def main(): parser = argparse.ArgumentParser(description="统一实验入口") parser.add_argument("--task", choices=["search", "ml", "nn"], required=True) parser.add_argument("--config", type=Path, default=Path("configs/default.yaml")) parser.add_argument("--seed", type=int, default=42) parser.add_argument("--out", type=Path, default=Path("runs")) args = parser.parse_args() set_seed(args.seed) # 种子在入口统一设置,避免各实验各写一遍 args.out.mkdir(parents=True, exist_ok=True) # 后续按 args.task 分发到对应模块,结果统一写到 args.out print(f"task={args.task} config={args.config} seed={args.seed}") if __name__ == "__main__": main()第二个方向是把 Notebook 降级。所有演示性质的内容留在 Notebook,逻辑一律抽到src/下的包里,Notebook 只保留调用和画图。改造完之后你会发现自己再也不想在单元格里写训练循环了。
第三个方向是补一个最小测试集,不用追求覆盖率,只测三类东西:数据加载能否返回正确形状、模型前向一次能否输出预期维度、搜索算法在一张手写的小地图上能否给出已知最优解。这三条能挡住绝大多数低级回归。
| 改造方向 | 具体动作 | 验证方式 |
|---|---|---|
| 统一入口 | 收敛为单一 CLI,参数走配置文件 | 同一条命令换--seed两次,结果一致 |
| 抽离逻辑 | Notebook 逻辑迁到src/,只留调用 | Notebook 重启内核从头运行无报错 |
| 最小测试 | 数据形状、模型输出、搜索最优解 | pytest -q全部通过 |
改造顺序上我建议先做第三个方向。听起来反直觉,但补测试的过程会逼你把每个模块的输入输出边界想清楚,后面改入口和抽包才有参照物。我是先重构再补测试吃过亏的:重构过程中把一处状态去重的逻辑改错了,没有任何报错,只是结果悄悄变差了,等发现问题时代码已经改了好几轮,只能回滚重来。从那以后我每次动核心逻辑之前,都强制先跑一遍测试再动第二行代码,宁可多写二十行断言,也不想再来一次那种排查。
这套实验包本身可能只是某门课的作业集合,但它把算法、数据流程、训练循环三条线摆在了同一个目录下,正好能当自己项目的起点。希望帮到你。
本文还有配套的精品资源,点击获取