📑 本页目录(点开跳转)
17 · 实战项目
⏱ 三个项目,每个 1–2 天 | ⭐ 不做项目 = 没学过
🎯 一句话
三个递进项目:表格数据全流程 → 手写神经网络 → 图像迁移学习。做完你就有了完整的手感。
💡 全部不需要 GPU,笔记本就能跑。
🧠 做项目之前:三条能救你很多时间的规矩
| 规矩 | 为什么 |
|---|---|
| 每次只改一件事 ⭐ | 同时改模型和特征,涨了你不知道是谁的功劳,掉了也不知道回滚哪个 |
| 每一步都记进表格 | 不记的话两小时后你会忘记"上一版是多少",然后重复实验 |
| 先跑通全流程再优化 | 哪怕 baseline 分数很烂。先有一条能从数据跑到提交的完整链路,比一个精雕细琢的半成品有用得多 |
💡 ADHD 友好的做法:每个 T 任务标了时间,做完一个就打勾并记一行分数。 卡住超过 25 分钟就跳到下一个 T,回头再补 —— 卡死在一个点上是最大的时间黑洞。
🥉 项目一:表格数据完整流程(1 天)
目标:在一个真实数据集上走完「数据 → 特征 → 模型 → 评估」,并证明自己没有自欺欺人。
练什么:第 4、5、16 章。
任务清单
- [ ] T1 (30min) 选数据集:sklearn 自带的
fetch_california_housing(回归)或load_wine(分类)。 先看数据:形状、缺失、分布、标签平衡度 - [ ] T2 (45min) 搭 Pipeline(标准化 + 模型),跑一个最简 baseline(逻辑回归/线性回归), 记下 CV 分数 ± 标准差
- [ ] T3 (45min) 换 GBDT,对比。只改模型,不改别的
- [ ] T4 (60min) 特征工程:加 3–5 个构造特征(比值、分箱、交叉),每加一个跑一次 CV,记录变化
- [ ] T5 (45min) 调参:先调
max_depth/num_leaves,再调learning_rate+n_estimators(第 4 章) - [ ] T6 (30min) ⭐ 诚实性检查(这一步才是这个项目的灵魂)
🔨 T2 的骨架(照抄就能开始)
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import cross_val_score, KFold
cv = KFold(5, shuffle=True, random_state=0)
def score(model, X, y, name):
s = cross_val_score(model, X, y, cv=cv, scoring="r2") # 或 "roc_auc"
print(f"{name:24s} {s.mean():.4f} ± {s.std():.4f}")
return s.mean(), s.std()
score(make_pipeline(StandardScaler(), LinearRegression()), X, y, "baseline")
🎯 T6 诚实性检查清单
① 同一配置换 3 个随机种子跑 → 分数的波动有多大?
② 你的"提升"有没有超过这个波动?
· 提升 0.003,种子间波动 0.015 → ❌ 这不是提升,是噪声
③ 所有预处理都在 Pipeline 里吗?(标准化、填充、PCA、编码)
④ 有没有用到"预测时拿不到"的特征?(第 5 章的数据泄漏)
⑤ 你调参用的验证集,和你报告分数用的是同一个吗?→ 如果是,分数虚高 ⭐
🔑 第 ⑤ 条最隐蔽:你用 CV 调了 50 组参数,选了最好的,然后报告那个 CV 分数—— 这个分数已经被你"选"出来了,是乐观的。 正确做法:留一个从头到尾没碰过的测试集,最后只用一次。
✅ 通关标准
- 有一张「baseline → +特征 → +调参」的分数演进表,每行带 ± 标准差
- 能说出哪些改动的提升超过了噪声,哪些没有
- Pipeline 里没有任何在 CV 外部做的预处理
🎁 加分
- 画学习曲线,判断该加数据还是改模型(第 5 章)
- 用 Permutation Importance 而非自带 importance 看特征贡献 (自带的对高基数特征有偏,第 16 章讲过)
- 看一眼预测最差的 20 个样本——它们有共同点吗?那就是下一个特征的线索 ⭐
🥈 项目二:手写神经网络(1–2 天)
目标:不用 PyTorch,纯 numpy 实现一个多层网络并训练到收敛。
练什么:第 7、8、9、10 章 —— 这个项目做完,反向传播就再也忘不掉了。
任务清单
- [ ] T1 (60min) 实现前向:
Linear→ReLU→Linear→Softmax,能跑通形状 - [ ] T2 (90min) 实现反向传播(第 8 章的模板扩展到多层 + 多分类)
- [ ] T3 (45min) ⭐ 梯度检验:数值梯度 vs 解析梯度,相对误差必须 < 1e-6。不通过就别往下走
- [ ] T4 (60min) 加 mini-batch 训练循环 + 交叉熵损失,
在
load_digits(8×8 手写数字,sklearn 自带)上训练 - [ ] T5 (60min) 加两个优化器:SGD+Momentum 和 Adam,对比收敛曲线
- [ ] T6 (45min) 加正则:L2 和 Dropout,观察训练/验证 loss 差距的变化
- [ ] T7 (30min) 和 sklearn 的
MLPClassifier对比精度,验证自己实现是对的
🔑 关键提示
# Softmax + 交叉熵的梯度(和第8章的 sigmoid 版一样干净)
import numpy as np
def softmax(z):
z = z - z.max(axis=1, keepdims=True) # ⭐ 数值稳定,防 exp 溢出
e = np.exp(z)
return e / e.sum(axis=1, keepdims=True)
# 前向:probs = softmax(logits)
# 反向:dlogits = (probs - y_onehot) / n ← 就这么简单
# ReLU 的反向
dz = da * (z > 0)
⚠️ 形状对不上的自救表(这个项目 80% 的时间会耗在这)
记住一条:【梯度的形状 = 它对应的那个量的形状】
W: (in, out) → dW 必须是 (in, out)
b: (out,) → db 必须是 (out,)
a: (batch, in) → da 必须是 (batch, in)
出错时打印出来对照:
print(name, "grad", g.shape, "param", p.shape)
| 现象 | 多半是 |
|---|---|
dW 形状反了 |
忘了转置:dW = a.T @ dz(不是 dz @ a.T) |
db 是二维的 |
忘了对 batch 求和:db = dz.sum(axis=0) |
| loss 是 NaN | ①学习率太大 ②softmax 没减最大值 ③log(0) → 加 1e-12 ⭐ |
| loss 完全不动 | ①忘了更新参数 ②学习率太小 ③梯度全是 0(ReLU 全死,初始化太小) |
| 训练集精度只有 10% | 就是随机猜(10 类)→ 大概率是标签和预测的对应错位了 |
🎯 该看到的数字(对照用)
digits 数据集(1797 张 8×8),架构 64 → 64 → 10:
梯度检验相对误差 ~1e-8 ~ 1e-9 ✅(参考实现实测 1.49e-08)
SGD 收敛 ~200 epoch
Adam 收敛 ~50 epoch ← 明显更快 ⭐
测试精度 95% ~ 97%
✅ 通关标准
- 梯度检验通过(相对误差 < 1e-6)—— 这是唯一的硬标准
- 在 digits 上测试精度 > 95%
- Adam 的收敛曲线明显快于 SGD
- 加 Dropout 后训练/验证 gap 缩小
🔗 想更进一步 → 挑战 A 手搓 mini-torch, 那里要求你实现通用的自动微分,而不是写死的四层网络。 🔗 数学不放心 → 数学原理第 12 章 有完整推导。
🥇 项目三:图像迁移学习(1–2 天)
目标:用预训练模型做一个小型图像分类,体验「不从零训练」的威力。
练什么:第 12、15 章。
任务清单
- [ ] T1 (45min) 数据:
torchvision.datasets.CIFAR10(自动下载,170MB)或自己找 3–5 类各 100 张图 - [ ] T2 (30min) ⭐ 先跑第 0 步:10 张图能过拟合吗?(第 11 章)
- [ ] T3 (60min) 基线 A:从零训练一个小 CNN,记录精度和耗时
- [ ] T4 (60min) 基线 B:ResNet18 预训练 + 冻结主干只训分类头
- [ ] T5 (60min) 基线 C:ResNet18 预训练 + 全部微调(lr 小 10 倍)
- [ ] T6 (45min) 加数据增强(翻转、随机裁剪),看 B 和 C 各涨多少
- [ ] T7 (30min) 三个方案的对比表:精度 / 训练时间 / 数据需求
🔨 B 和 C 的关键代码差异(就这几行)
import torchvision, torch.nn as nn
m = torchvision.models.resnet18(weights="IMAGENET1K_V1")
m.fc = nn.Linear(m.fc.in_features, n_classes) # 换分类头(两个方案都要)
# ---- 方案 B:冻结主干 ----
for p in m.parameters():
p.requires_grad = False
for p in m.fc.parameters():
p.requires_grad = True
opt = torch.optim.AdamW(m.fc.parameters(), lr=1e-3)
# ---- 方案 C:全微调(分层学习率)----⭐
opt = torch.optim.AdamW([
{"params": [p for n, p in m.named_parameters() if not n.startswith("fc")],
"lr": 1e-4}, # 主干:小 10 倍
{"params": m.fc.parameters(), "lr": 1e-3}, # 新头:大
])
⚠️ 三个必踩的坑:
| 坑 | 后果 | 解法 |
|---|---|---|
| 忘了用预训练的归一化参数 | 精度莫名低 5~10% | 必须用 ImageNet 的 mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225] ⭐ |
冻结主干时忘了 bn.eval() |
BN 的 running stats 被新数据带跑 | 冻结时整个主干 .eval() |
| 全微调用了和从零训练一样的学习率 | 把预训练权重直接冲垮,效果比 B 还差 | 主干 lr 小 10 倍 ⭐ |
🎯 该看到的现象
CIFAR10 全量(50000 张):
A 从零小CNN ~70%
B 冻结主干 ~75% ← 领先不多
C 全微调 ~93% ⭐
只用 100 张训练图:
A 从零小CNN ~25% ← 几乎崩了
B 冻结主干 ~65% ⭐ ← 差距拉开到 40 个点!
C 全微调 ~60% ← 反而不如 B(数据太少,微调会过拟合)
🔑 这三组数字回答了"什么时候冻结、什么时候全微调": 数据少 → 冻结(可训参数少,不容易过拟合); 数据多 → 全微调(能让特征适配你的领域)。 分界大概在每类几百张。
✅ 通关标准
- 迁移学习(B/C)明显优于从零训练(A),且能解释为什么
- 数据增强带来可测量的提升
- 能说出「数据量多少时该冻结、多少时该全微调」
🎁 加分
- 故意只用 100 张训练图,看三个方案的差距会不会拉大 (这才是迁移学习真正的价值,也是上表最有意思的一行)
- 可视化第一层卷积核,看它学到了什么(边缘、颜色块)
- 画混淆矩阵,看哪两类最容易混——通常是语义上真的相似的那两类
📝 项目 README 模板
# 项目名
## 问题定义(数据、任务、指标)
## 结果
| 方案 | CV 分数 | 测试分数 | 备注 |
|------|---------|----------|------|
| baseline | 0.812 ± 0.021 | 0.805 | |
| +特征工程 | 0.847 ± 0.018 | 0.841 | 提升超过噪声 ✅ |
| +调参 | 0.853 ± 0.019 | 0.845 | 提升在噪声内 ⚠️ |
## 关键决策与依据
## 踩过的坑 ← 最加分
💡 "踩过的坑"那一节别省。面试时它比分数有说服力得多—— 分数说明你会调,坑说明你懂原理。
✅ 检查点
- 做项目的三条规矩是什么?为什么"先跑通全流程"比"精雕细琢半成品"重要?
- 诚实性检查里最隐蔽的一条是哪个?正确做法是什么?
- 手写神经网络时,梯度形状怎么快速判断对不对?
- loss 变成 NaN 的三个常见原因?
- loss 完全不动的三个常见原因?
- 迁移学习为什么必须用 ImageNet 的归一化参数?
- 全微调时主干为什么要用更小的学习率?
- 数据只有 100 张时,冻结和全微调哪个更好?为什么?
👀 答案
- ①每次只改一件事(否则不知道是谁的功劳)②每步记进表格 ③先跑通全流程。因为一条完整链路能立刻告诉你瓶颈在哪,半成品连"到底哪一环最弱"都看不出来。
- 用来调参的验证集和报告分数的是同一个 —— 你从 50 组参数里选了最好的,那个分数已经被"选"出来了,是乐观的。正确做法:留一个从头到尾没碰过的测试集,最后只用一次。
- 梯度的形状 = 它对应的那个量的形状。W 是 (in,out) → dW 必须是 (in,out);b 是 (out,) → db 必须是 (out,)。
- ①学习率太大 ②softmax 没减最大值导致 exp 溢出 ③log(0),要加 1e-12。
- ①忘了更新参数 ②学习率太小 ③梯度全为 0(ReLU 全死 / 初始化太小)。
- 因为预训练权重是在那个归一化下学到的。输入分布一变,所有层的激活分布都偏了,精度会莫名低 5~10%。
- 因为预训练权重已经很好了,大学习率会直接把它冲垮,效果甚至不如只训分类头。分层学习率:主干 1e-4,新头 1e-3。
- 冻结更好(约 65% vs 60%)。因为可训练参数少得多,100 张数据不足以支撑全微调,会过拟合。分界大概在每类几百张。
🛑 完成了?
三个高难度挑战在等你:
- ⚙️ 挑战A · 手搓 mini-torch —— 从零造一个自动微分框架
- 🥊 挑战B · 四种解法对决 —— 亲手验证「没有免费的午餐」
- 🔬 挑战C · 过拟合实验室 —— 故意制造并诊断 10 种训练故障
⚡ 走神救援
三规矩:每次只改一件事、每步记表格、先跑通全流程再优化(卡住超25分钟就跳过回头补)。三项目:①表格全流程(Pipeline+CV+特征工程+调参,灵魂是诚实性检查:换3个种子看波动,提升有没有超过噪声;⭐最隐蔽的坑是"调参的验证集=报告分数的验证集",必须留一个从没碰过的测试集)②纯numpy手写神经网络(硬标准=梯度检验<1e-6;80%时间耗在形状上——记住"梯度形状=对应量的形状";NaN=学习率大/softmax没减最大值/log(0);不动=没更新/lr太小/ReLU全死。参考数字:Adam 50轮 vs SGD 200轮,精度95-97%)③图像迁移学习(必须用ImageNet的归一化参数否则掉5-10%;全微调主干lr要小10倍否则冲垮预训练权重;⭐关键实验:只用100张图时冻结(65%) > 全微调(60%) > 从零(25%)——数据少就冻结,数据多才全微调)。README里"踩过的坑"最加分。