AI实验课全链路指南:从Python环境到评估指标闭环
简介:这份2023年春季复旦大学人工智能A实验课程资料,面向希望复现课程实验、梳理AI基础方法并完成课程项目的高校学生与自学者。包体共52个文件,以43个Python源码为核心,辅以4个Markdown说明、3个PDF实验或项目文档及2个数据压缩包,整体约2.35MB,覆盖实验代码、数据、报告与项目材料等环节。已有108人学习,说明其具备一定的课程参考价值。内容围绕课程实验与项目展开,可从中了解数据集处理、模型训练与评估、命名实体识别等任务的实现思路,也能参考实验报告的组织方式、项目文档的说明结构和代码目录划分,便于对照复现实验流程、补充课程作业或整理自己的AI实验笔记。适合具备Python基础、正在学习机器学习与人工智能课程,且需要一套完整课程实验样例作为参照的读者。
1. 一份AI实验课的知识地图:从Python代码到评估指标的闭环
这门实验课的名字里挂着“人工智能”和“AI”,真上手才会发现它考的不是谁能背出概念,而是一条完整链路能不能跑通:数据进来、特征出去、模型训练、指标回收,四段扣在一起才算做完。很多人第一次做把它当成调包课,结果卡在环境和张量形状上耗掉两周,血泪经验。整张地图大概分四块:Python 与科学计算栈是地基,数据准备决定上限,模型与训练流程是主干,评估与复现是收口。它适合两类人——正在系统学、想先看清全貌再动手的;以及期末复习、需要把七八个零散实验串成一条线的。最容易忽略的是第一块和第四块,它们平时不显眼,却决定了后面每一步是可复现的工程还是玄学。
2. 环境与Python基本功:跑不起来的坑大多在这
实验课的第一道坎几乎从来不是算法,而是“为什么我这里报错”。同一份参考代码,在机房里能跑,在你自己电脑上报ModuleNotFoundError;昨天还能训练的脚本,今天import就报版本不兼容。这些现象背后其实只有三个变量:解释器版本、依赖版本、以及当前激活的到底是哪个环境。把这三件事管住,后面百分之八十的“跑不起来”会自动消失。
2.1 环境三件套:解释器、虚拟环境与依赖锁定
先说要解决的问题。一个学期下来,你会同时用到数值计算、绘图、传统机器学习和深度学习四套库,它们的版本要求并不总是一致;如果所有包都往系统 Python 里装,迟早会撞车。虚拟环境的作用就是把这门课的依赖关进一个小房间,装坏了直接删掉重建,不用重装整个系统。
# 只给这门实验建一个环境,避免和其他课程的依赖打架 conda create -n ai-lab python=3.10 -y conda activate ai-lab # 装完核心依赖后立刻把版本钉死,方便下次复现 pip install numpy pandas matplotlib scikit-learn torch pip freeze > requirements.txt-n ai-lab是环境名,起个好记的,别用默认名;python=3.10是把解释器版本钉死,这一步比装什么包更重要,因为不同小版本对某些库的 wheel 支持不一样。最后的pip freeze > requirements.txt会把当前环境里所有包的精确版本导出,换机器时pip install -r requirements.txt就能还原到同一套。判断标准很简单:如果你没法用一条命令重建出当前环境,那这次实验的结果就不算可复现。
还有一件常被跳过的事——确认你此刻用的到底是哪个解释器。在脚本开头打印一行就能看清楚:
import sys print(sys.executable) # 输出当前解释器的绝对路径,确认不是系统自带的那个如果这行输出指向的不是你刚建的环境目录,说明编辑器里的解释器还没切过来。这类问题在 IDE 里尤其隐蔽:终端里跑得好好的,点“运行”就报错,原因多半是 IDE 记着另一个解释器。
提示:pip 和 conda 混着装同一个包,最后
import到哪个版本只能靠打印路径确认,别靠猜。
2.2 NumPy 的轴与广播:向量化写法为什么快
NumPy 是这门课所有计算的底座,而它最容易把人绕晕的是两件事:axis到底沿哪个方向压缩,以及广播规则。前者决定你算出来的是每列还是每行的统计量,后者决定你能不能把循环写成一行而不报维度错误。
记忆方式不用背定义,只看形状。一个(N, D)的数组,axis=0表示“把第 0 维压掉”,结果形状是(D,),也就是对每一列求和;axis=1压掉第 1 维,结果形状是(N,),对每一行求和。凡是拿不准的时候,先print(a.shape),再推结果形状,比死记“0 是行 1 是列”可靠得多。
import numpy as np # X: (样本数 N, 特征数 D),w: (D,),b 是标量 X = np.random.randn(100, 4) w = np.array([0.5, -1.2, 0.3, 2.0]) b = 0.1 # (100,4) @ (4,) -> (100,),得到每个样本的预测值,无需写循环 y = X @ w + b print(y.shape) # (100,) print(X.sum(axis=0).shape) # (4,),每列的和 print(X.sum(axis=1).shape) # (100,),每行的和@是矩阵乘法,*是逐元素相乘,这两个符号在实验里被混用是高频错误。广播规则可以压缩成三条:从最后一维往前比,维度相等可以;某一维是 1 可以拉伸;维度缺失就补 1。三条都不满足才报错。理解这三条之后,(100, 4) @ (4,)这种写法就不用再怀疑了。
| 写法 | 含义 | 常见误用 |
|---|---|---|
a * b | 逐元素相乘 | 当成矩阵乘法,结果形状对但数值全错 |
a @ b | 矩阵乘法 | 维度不匹配时误以为是库的问题 |
a.sum(axis=0) | 沿第 0 轴压缩,得每列的和 | 以为得到每行的和 |
a.reshape(-1, 1) | 变成列向量 | 与reshape(1, -1)混用导致广播出错 |
向量化的意义不只是“写得短”。在同样的数据规模下,一次矩阵乘法和一层 Python 循环的耗时能差出一到两个数量级,实验里动辄上万次迭代,这个差距会直接反映在你能不能在下课前跑完。
2.3 Pandas 的索引与那些不报错的静默问题
Pandas 的坑和 NumPy 不一样:它很少报错,更多是悄悄给你一个错的结果。最典型的是链式索引——df[df["score"] > 60]["level"] = "pass"这种写法在某些版本下改的是临时副本,原表纹丝不动,你还以为赋值成功了。正确做法是用loc一次写完行列条件,需要独立副本时显式.copy()。
import pandas as pd df = pd.read_csv("data.csv") # 用 loc[行条件, 列名] 一次定位,避免链式索引改不动原表 df.loc[df["score"] < 60, "flag"] = 1 # 需要独立副本时显式 copy,别指望切片一定返回视图 sub = df.loc[df["score"] >= 60].copy() sub["level"] = "pass"第二个静默问题是merge之后行数变多。两张表如果键有重复,内连接会让结果膨胀,而代码不会提醒你。养成习惯:合并前后各打一次shape,对不上就回去查键的唯一性。第三是缺失值的比较——df["x"] == np.nan永远是 False,判断缺失只能用isna()。这类细节在数据清洗阶段不显眼,等到模型指标异常再回头找,往往要重跑一整轮。
2.4 报错速查:先看这五类
| 报错/现象 | 大概率原因 | 第一步查什么 |
|---|---|---|
ModuleNotFoundError | 环境没激活或装错环境 | sys.executable指向哪里 |
shape mismatch | 广播规则不满足 | 打印两边的.shape |
损失变成nan | 学习率过大或出现log(0) | 学习率调小一个量级,检查输入范围 |
| 准确率恒定不变 | 标签没对齐或全预测同一类 | 看混淆矩阵而非准确率 |
| 结果每次都不一样 | 随机种子没固定 | 在入口处统一设seed |
这张表的作用是缩短定位时间,不是替代理解。真正的判断顺序是:先确认环境,再确认形状,最后才怀疑算法。把顺序反过来,往往会在一个拼写错误上耗掉半天。
3. 数据准备与特征工程:实验分数的分水岭
同样一套模型代码,换一份处理过的数据,指标能差十几个点。这门课里真正拉开差距的往往不是谁用了更复杂的网络,而是谁把数据切对了、缩放对了、编码对了。这一块没有炫技空间,但每一步都有明确的判断标准,属于“做对了不加分,做错了全盘皆输”的类型。
3.1 划分与泄漏:三个集合的边界不能模糊
训练集、验证集、测试集的分工必须一次划清:训练集用来拟合参数,验证集用来选超参和早停,测试集只在最后看一次。最常见的错误不是不知道这个定义,而是操作顺序反了——先对全量数据做标准化,再切分。这样测试集的均值方差信息已经渗进了训练过程,指标会虚高,等到真正部署时原形毕露。
from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler # 先切分,再在训练集上 fit 标准化器,顺序反了就是数据泄漏 X_tr, X_te, y_tr, y_te = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42 ) scaler = StandardScaler().fit(X_tr) # 只在训练集上学习均值和方差 X_tr_s = scaler.transform(X_tr) X_te_s = scaler.transform(X_te) # 测试集只做变换,不参与 fitstratify=y保证切分后各类比例一致,类别不平衡时尤其重要;random_state固定切分结果,方便复现。所有“从数据里学习统计量”的步骤——标准化、归一化、缺失值填充、特征选择——都必须只在训练集上 fit,然后 transform 到其他集合。这条规则可以一句话记住:任何用到全局统计量的操作,都要放在切分之后。
交叉验证是同一原则的延伸。用cross_val_score时,标准化应该放进Pipeline,让它在每一折内部各自 fit,而不是在外面先做完再传进去。
3.2 缺失值、异常值与缩放:先判断再动手
缺失值处理的第一步不是填,而是看缺失比例和缺失机制。某列缺失超过一半,通常直接删列比硬填更稳;缺失比例低且看起来随机,用中位数或众数填充就够了;如果缺失本身带有信息(比如“未填写”往往对应某类样本),可以额外加一列缺失标记。
异常值同理。箱线图的 1.5 倍四分位距是一个可用的判断线,但删不删要看业务含义:传感器偶发跳变该删,长尾分布里的真实极值不该删。实验里比较稳妥的做法是先做记录,把异常点标记出来,训练一版不处理的模型作为基线,再对比处理后的效果,用验证集指标说话。
缩放的选择取决于模型。基于距离的算法(KNN、SVM、K-Means)和带正则的线性模型对尺度敏感,必须标准化;树模型对单调变换不敏感,标准不标准差别不大,但也不会有坏处。判断标准很朴素:如果你的模型里有“距离”或“梯度”在起作用,就缩放。
3.3 类别编码与不平衡:别让多数类主导指标
类别特征有两种处理路线。类别之间没有大小关系(颜色、城市、类别名)用独热编码,维度高时改用目标编码或频次编码;有明确顺序(低、中、高)用序数编码,但要保证编码顺序和真实顺序一致。独热编码最容易被忽略的一点是训练集和测试集的列要对齐——测试集里出现训练集没有的类别时,pd.get_dummies直接调用会导致列数不一致,正确做法是先对训练集编码,再用reindex(columns=...)对齐测试集。
不平衡数据的处理顺序是:先换指标,再考虑采样。准确率在 95:5 的数据上毫无意义,全预测多数类也能拿 95 分。改用 F1、AUC 或召回率之后,如果还不够,再考虑类别权重(class_weight="balanced")、过采样或欠采样。采样一定要在训练集内部做,别在切分前动全量数据,否则又是一次泄漏。
4. 模型与训练主线:从线性模型到神经网络的一条线
这门课涉及的模型看起来跨度很大,从线性回归一路到卷积网络,但训练范式其实只有一套:定义假设、选损失、求梯度、更新参数、在验证集上判断何时停。把这套骨架立起来,换模型只是换假设和损失的具体形式,复习时也不用一个模型背一遍。
4.1 损失函数与正则:分类和回归的分岔点
回归用均方误差,分类用交叉熵,这两句话背后的区别在于假设的分布不同。均方误差对应高斯噪声假设,交叉熵对应伯努利或多项分布。实验里最常见的错误是把交叉熵用在了回归任务上,或者在做二分类时忘了把输出过 sigmoid 就直接送进BCELoss,导致损失为nan。
正则项的作用是限制参数幅度,L2 让权重整体偏小且平滑,L1 倾向于产生稀疏解、可以直接当特征选择用。正则强度是需要调的,调法是在验证集上看:正则太弱,训练损失一路下降但验证损失早早回升;正则太强,两个损失都降不下去。这条曲线是判断过拟合与欠拟合最直接的证据。
from sklearn.linear_model import LogisticRegression # C 是正则强度的倒数:C 越小,正则越强,模型越保守 clf = LogisticRegression(C=0.1, penalty="l2", class_weight="balanced", max_iter=1000) clf.fit(X_tr_s, y_tr) print(clf.score(X_te_s, y_te))C的取值建议按数量级试(0.01、0.1、1、10),而不是在小数点后微调。max_iter报收敛警告时,先加迭代次数,再怀疑数据没缩放——特征尺度差异过大时,梯度下降收敛会非常慢。
4.2 树模型与集成:可解释性和稳定性的取舍
决策树的好处是可视化、可解释,坏处是方差大,数据稍微一变结构就完全不同。随机森林用自助采样加特征随机把方差压下来,梯度提升则用串行拟合残差的方式降偏差。这两条路线的取舍很清晰:样本量小、噪声大时森林更稳;追求极致指标且愿意调参时用提升类方法。
树模型有两个必须掌握的输出:特征重要性和单棵树的深度。前者帮你判断特征工程是否有效——如果某个明显该重要的特征重要性接近零,多半是编码或缺失值处理出了问题;后者帮你判断是否过拟合——树深到十几层基本就是在记住训练集了。
| 模型 | 主要超参 | 什么时候优先考虑 | 典型风险 |
|---|---|---|---|
| 逻辑回归 | C、penalty | 需要系数解释、样本线性可分 | 特征共线性导致系数不稳 |
| 决策树 | max_depth、min_samples_leaf | 需要规则可读 | 不剪枝必过拟合 |
| 随机森林 | n_estimators、max_features | 表格数据基线 | 特征重要性偏向高基数特征 |
| 梯度提升 | learning_rate、n_estimators | 追求指标上限 | 调参成本高、易过拟合 |
4.3 神经网络最小骨架:前向、损失、反向、更新
深度学习部分不用一上来就搭复杂结构,先把四行循环写熟:清梯度、算损失、反向传播、更新参数。这四步顺序错一步,训练就会以各种奇怪的方式失败——最经典的是忘了清梯度,梯度在 batch 之间累加,损失一路飙升。
import torch import torch.nn as nn model = nn.Sequential( nn.Linear(4, 16), nn.ReLU(), nn.Linear(16, 3) # 3 分类输出,不用手动加 softmax ) criterion = nn.CrossEntropyLoss() # 内部已包含 log_softmax optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) for epoch in range(50): model.train() for xb, yb in train_loader: optimizer.zero_grad() # 不清零,梯度会在 batch 间累加 loss = criterion(model(xb), yb) loss.backward() optimizer.step() model.eval() # 切换 Dropout / BN 的行为 with torch.no_grad(): # 验证阶段不需要建计算图 for xb, yb in val_loader: logits = model(xb)CrossEntropyLoss吃的是未激活的 logits,所以模型最后一层不要加 softmax;自己加了再送进去,等于做了两次归一化,损失会一直降不下去。model.train()和model.eval()的切换在有 Dropout 或 BatchNorm 时必须严格成对出现,忘了切到eval会让验证指标随机抖动,看起来像模型不稳定,其实是层的行为没切过来。学习率从1e-3起步,损失变nan就降一个量级重来,这比盯着代码找 bug 快得多。
4.4 指标与早停:验证集怎么用才不浪费
指标的选择要和任务匹配,而不是顺手用默认的准确率。
| 任务类型 | 主指标 | 辅助看什么 | 什么情况下主指标会骗人 |
|---|---|---|---|
| 二分类、类别不平衡 | F1 / AUC | 召回率、精确率 | 准确率高但全预测多数类 |
| 多分类 | 宏平均 F1 | 混淆矩阵 | 少数类被整体忽略 |
| 回归 | RMSE / MAE | 残差分布 | 少量极端值把 RMSE 拉偏 |
早停是这套流程里性价比最高的一招:每轮在验证集上算一次指标,连续若干轮不提升就停,并保留验证指标最好的那组参数。它同时解决了两个问题——省训练时间,以及避免在过拟合的区域白调参。要注意的是,早停依据的是验证集,测试集在整个调参过程中一次都不能碰,否则最后那个数字就没有参考价值了。
5. 易错点清单:五个最容易在实验里翻车的地方
踩过的坑值得单列一章,因为这些错误有一个共同特征——代码能跑通、指标也出得来,但结论是错的,不报错的错误最难发现。
5.1 易错点一:标准化做在切分之前
易漏点:习惯性地先fit_transform全量数据,再切训练测试集,代码风格上毫无违和感。
为什么漏:这一步不会报错,指标通常还更好看,因为测试集的分布信息泄漏进了训练过程。指标虚高的正向反馈会让人失去警觉。
怎么补:把所有用到全局统计量的操作(标准化、填充、编码、特征选择)都塞进Pipeline,让它们在交叉验证的每一折内部独立 fit。验收方法很简单:把测试集整体打乱重跑一遍,如果指标掉得离谱,说明之前有泄漏。
5.2 易错点二:拿测试集反复调参
易漏点:调一次超参就在测试集上跑一次,跑了几十次之后挑了个最好的。
为什么漏:测试集就在手边,改动成本为零,而验证集还要额外切一刀,看起来麻烦。问题是这样等于把测试集变成了第二个验证集,最后报告的数字是一种乐观偏差。
怎么补:训练集训参数、验证集选超参、测试集只做最终评估,这条线一次都不能破。实验报告里建议同时给出验证集和测试集的结果,两者差距过大本身就是过拟合的信号。
5.3 易错点三:训练循环里的状态没切换
易漏点:忘了optimizer.zero_grad(),或者验证阶段忘了model.eval()。
为什么漏:这两处都不会抛异常。梯度累加表现为损失突然暴涨,容易被误判成学习率太大;eval没切表现为验证指标抖动,容易被归因为“神经网络就是玄学”。
怎么补:把训练循环写成固定模板,zero_grad放在每个 batch 的开头,train()/eval()成对出现。排查时先打印一轮梯度的范数,如果数值异常大,先怀疑梯度没清零再怀疑模型结构。
5.4 易错点四:随机种子没有统一固定
易漏点:只固定了train_test_split的种子,忘了 NumPy、PyTorch 和 DataLoader 的。
为什么漏:单次运行完全正常,只有当你想复现上一次的“最好结果”时才会发现对不上。实验报告里写“准确率 0.87”但自己重跑变成 0.84,是很尴尬的事。
怎么补:在入口脚本统一设一次,覆盖random、numpy.random、torch.manual_seed和 DataLoader 的generator。同时把数据切分、模型初始化、采样顺序都纳入同一条种子链路,再配合requirements.txt锁定版本。
import random, numpy as np, torch def set_seed(seed: int = 42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) # CPU 与单卡都覆盖 torch.backends.cudnn.deterministic = True # 关掉非确定性算子选择注意最后一行会影响一点速度,复习阶段为了结果可复现是值得的。生产环境追求吞吐时再关掉,取舍写进实验记录里。
5.5 易错点五:只看一个数字就下结论
易漏点:分类只看准确率,回归只看 MSE,不做混淆矩阵也不看残差。
为什么漏:一个数字最省事,也最容易被当作“结果”。但类别不平衡时准确率会严重高估,回归里的少数极端值会把 MSE 拉得很大,掩盖了模型在大多数样本上的表现。
怎么补:分类必看混淆矩阵和每类召回,回归必看残差分布和分位数误差。再加一条:任何一次指标异常波动,先怀疑数据而不是模型——检查标签分布、特征范围、切分比例是否和上一轮一致。
6. 复习清单与查漏补缺:把实验从头重跑一遍
真正有效的复习不是重读讲义,而是把整条链路重跑一遍,看哪一步会卡住。我通常按下面的顺序过,每一步都有明确的通过标准,卡在哪一格就补哪一块。
| 检查项 | 通过标准 | 卡住先看哪 |
|---|---|---|
| 环境重建 | 新机器上pip install -r后脚本能跑 | 解释器路径、版本冲突 |
| 数据加载 | shape、dtype、缺失比例打印正常 | 编码、分隔符、列名空格 |
| 切分与缩放 | 标准化只在训练集 fit | Pipeline是否用上 |
| 基线模型 | 一个最简单的模型跑出可解释的指标 | 标签对齐、类别数 |
| 训练循环 | 损失单调下降,验证集同步记录 | 学习率、梯度清零 |
| 指标与报告 | 混淆矩阵 + 主指标 + 验证/测试对比 | 指标选错、测试集被调过 |
| 可复现 | 换台机器重跑,指标差异在小数点后 | 随机种子、依赖版本 |
复习时有个特别省时间的做法:先跑通一个“最小闭环”——用十行代码加载一份小数据、训一个逻辑回归、打印准确率。这条线通了,再把数据换成实验用的、模型换成复杂的。反过来做,一旦报错就分不清是数据问题、环境问题还是模型问题。
另一个查漏补缺的角度是“反向验证”。拿一个你已经知道结果的实验,故意改坏一处——把标准化放到切分前、把随机种子去掉、把zero_grad注释掉——看指标往哪个方向变。这个动作做三五次,你对每个易错点的敏感度会明显提高,考试里看到类似的问法也能立刻反应过来它在考什么。
还有一点值得单独拿出来说:实验报告里的图不要只截最后那张。训练曲线、混淆矩阵、残差分布这三张图,分别对应过拟合、类别偏差和异常值三个高频问题,把它们放在一起看,很多“指标上不去”的疑问不用问人就能自己定位。
从那以后我每次开新的实验,都强制走一遍“建环境、锁依赖、定种子、切数据、跑最小闭环”这五步,不管多简单的任务都不跳过。这五步花不到十分钟,但省下的排查时间往往是几小时起步。整理成压缩包的实验代码与笔记我放在同一处,需要对照着重跑的可以直接拿去用,希望帮到你。
本文还有配套的精品资源,点击获取