📑 本页目录(点开跳转)
16 · 实战项目
⏱ 三个项目,每个 1–3 天 | ⭐ 不做项目 = 没学过
🎯 一句话
看懂 ≠ 会做。这三个项目难度递进,做完第一个你就有作品集,做完第三个你能面推荐算法岗。
🧠 ADHD 专属:怎么保证你能做完项目
项目是最容易半途而废的东西。 三条规则:
- 先跑通最烂的版本。别一开始就想做完美。烂版本能跑 = 已经赢了 80%。
- 每个项目切成 5 个「一小时任务」(下面已经切好了)。每完成一个打勾。
- 不允许优化「还没跑通的东西」。跑通 → 打勾 → 才准优化。
🚫 必须避免:花三天调 Docker 环境。用 Colab / 本地裸 Python,能跑就行。
🥉 项目一:电影推荐(1 天)
目标:在 MovieLens 上跑通「数据 → 三个模型 → 评估对比」的完整流程。
产出:一个 Jupyter Notebook + 一张模型对比表。
任务清单
- [ ] T1 (30min) 下数据、看数据、算稀疏度和长尾分布
- [ ] T2 (45min) 按时间切分训练/测试集(别随机切!)
- [ ] T3 (60min) 实现 Baseline:热门推荐 + ItemCF
- [ ] T4 (60min) 用
implicit跑 ALS,和 baseline 对比 - [ ] T5 (45min) 实现 Recall@10 / NDCG@10,画对比图
🔨 起步代码
import pandas as pd, numpy as np, zipfile, urllib.request, io
from collections import defaultdict
# ---------- T1: 数据 ----------
url = "https://files.grouplens.org/datasets/movielens/ml-latest-small.zip"
with urllib.request.urlopen(url) as r:
z = zipfile.ZipFile(io.BytesIO(r.read()))
df = pd.read_csv(z.open("ml-latest-small/ratings.csv"))
# 只保留正反馈(评分 >= 4 视为"喜欢")—— 转成隐式反馈问题
df = df[df.rating >= 4.0].copy()
print(f"用户 {df.userId.nunique()}, 物品 {df.movieId.nunique()}, 交互 {len(df)}")
# ---------- T2: 按时间切分(关键!) ----------
df = df.sort_values("timestamp")
split_ts = df.timestamp.quantile(0.8)
train = df[df.timestamp <= split_ts]
test = df[df.timestamp > split_ts]
# 测试集里只保留训练集见过的用户(冷启动单独评估)
test = test[test.userId.isin(train.userId.unique())]
train_dict = train.groupby("userId").movieId.apply(list).to_dict()
test_dict = test.groupby("userId").movieId.apply(set).to_dict()
# ---------- T5: 评估函数(先写好,后面复用) ----------
def evaluate(recommend_fn, train_dict, test_dict, k=10):
recalls, ndcgs, hits = [], [], []
for user, truth in test_dict.items():
recs = recommend_fn(user, k)
if not recs: continue
hit_set = set(recs) & truth
recalls.append(len(hit_set) / len(truth))
hits.append(1.0 if hit_set else 0.0)
# NDCG
dcg = sum(1/np.log2(i+2) for i, item in enumerate(recs) if item in truth)
idcg = sum(1/np.log2(i+2) for i in range(min(len(truth), k)))
ndcgs.append(dcg/idcg if idcg > 0 else 0)
return {"Recall@%d" % k: np.mean(recalls),
"NDCG@%d" % k: np.mean(ndcgs),
"HitRate@%d" % k: np.mean(hits)}
# ---------- T3: Baseline 1 —— 热门推荐 ----------
popular = train.movieId.value_counts().index.tolist()
def rec_popular(user, k=10):
seen = set(train_dict.get(user, []))
return [i for i in popular if i not in seen][:k]
print("热门推荐:", evaluate(rec_popular, train_dict, test_dict))
# ---------- T3: Baseline 2 —— ItemCF ----------
# 👉 把第 4 节的 ItemCF 类复制过来,fit(train_dict),然后:
# print("ItemCF:", evaluate(lambda u,k: [i for i,_ in itemcf.recommend(train_dict[u], k)],
# train_dict, test_dict))
✅ 通关标准
- ItemCF 的 Recall@10 明显高于热门推荐
- ALS 又高于 ItemCF(如果没有,检查
alpha和factors参数) - 能说出为什么
🎁 加分项
- 加上第 4 节的热门惩罚 α,观察 Recall 和 Coverage 怎么此消彼长
- 计算 Coverage(推荐结果覆盖了多少比例的物品)
🥈 项目二:深度召回 + 排序两阶段(2–3 天)
目标:搭一个有召回有排序的完整两阶段系统。这是工业系统的最小可用形态。
产出:一个能输入 user_id、输出 top-10 的系统 + 分阶段的评估报告。
任务清单
- [ ] T1 (60min) 用 PyTorch 实现双塔模型(抄第 8 节的代码)
- [ ] T2 (90min) 实现 in-batch 负采样训练,跑通
- [ ] T3 (45min) 用 Faiss 建索引,实现召回,评估 Recall@200
- [ ] T4 (90min) 训练一个排序模型(DeepFM 或简单 MLP),特征包括:用户历史统计、物品统计、类目
- [ ] T5 (60min) 串联:召回 200 → 排序 → top-10,评估端到端 NDCG@10
- [ ] T6 (45min) 消融实验:只用召回 vs 召回+排序,差多少?
🔑 关键实现要点
# ---------- 召回负采样:必须全库随机采,不是"曝光未点击" ----------
# 见第 8 节 in_batch_softmax_loss
# ---------- Faiss 索引 ----------
import faiss
item_vecs = model.encode_item(all_item_ids).detach().numpy().astype('float32')
faiss.normalize_L2(item_vecs)
index = faiss.IndexFlatIP(item_vecs.shape[1]) # 数据量小,暴力就行
index.add(item_vecs)
def retrieve(user_id, k=200):
uv = model.encode_user(torch.tensor([user_id])).detach().numpy().astype('float32')
faiss.normalize_L2(uv)
_, ids = index.search(uv, k)
return ids[0]
# ---------- 排序阶段的负样本:从召回结果里采 ----------
# ⭐ 这一点很重要:排序模型的负样本分布要接近它线上面对的分布(召回结果)
def build_rank_samples(user, pos_items, retrieved, n_neg=4):
samples = [(user, i, 1) for i in pos_items]
negs = [i for i in retrieved if i not in pos_items]
samples += [(user, i, 0) for i in np.random.choice(negs, min(len(negs), len(pos_items)*n_neg), replace=False)]
return samples
✅ 通关标准
- 召回 Recall@200 > 0.3(能力好的可以到 0.5+)
- 端到端 NDCG@10 明显高于「只用召回结果的前 10 个」
- 能解释为什么召回负样本和排序负样本的采样方式不一样 ⭐ 这是面试题
🎁 加分项
- 加一路 ItemCF 召回,做多路合并,看 Recall 涨多少
- 在物品塔里加入类目/标签特征,测试新物品(训练集没出现过)的召回效果
🥇 项目三:端到端可运行的推荐服务(3–5 天)
目标:不只是 Notebook,是一个能被 HTTP 调用的服务,带监控和 A/B 分流。
产出:一个 GitHub 仓库,README 里有架构图和实验结果。这是能写进简历的东西。
任务清单
- [ ] T1 (2h) FastAPI 服务:
GET /recommend?user_id=x&n=10 - [ ] T2 (2h) 特征存储:Redis(或简化成 pickle 字典)存用户/物品特征
- [ ] T3 (2h) 完整链路:召回(Faiss)→ 排序(模型)→ 重排(打散+去重)
- [ ] T4 (2h) A/B 分流:哈希 user_id 分两组,跑两套策略
- [ ] T5 (2h) 埋点日志 + 简单看板(曝光数、各路召回占比、pCTR 分布、P99 延迟)
- [ ] T6 (2h) 降级:加超时保护和热门兜底
- [ ] T7 (2h) 写 README:架构图、实验对比表、踩坑记录
🔨 服务骨架
from fastapi import FastAPI
import hashlib, time, logging
app = FastAPI()
log = logging.getLogger("rec")
def assign_group(user_id: int, exp_id: str = "exp_001") -> str:
"""A/B 分流:哈希要带 exp_id,否则多个实验会用同一个分桶"""
h = int(hashlib.md5(f"{exp_id}_{user_id}".encode()).hexdigest(), 16)
return "treatment" if h % 100 < 50 else "control"
@app.get("/recommend")
def recommend(user_id: int, n: int = 10):
t0 = time.time()
group = assign_group(user_id)
try:
# 1. 特征
user_feat = feature_store.get_user(user_id)
# 2. 召回(多路)
cands = set()
cands |= set(vector_recall(user_feat, k=200))
cands |= set(itemcf_recall(user_feat, k=100))
cands |= set(hot_recall(k=50))
cands -= set(user_feat["history"]) # 去重
cands = list(cands)
# 3. 排序(A/B:两组用不同模型)
model = model_v2 if group == "treatment" else model_v1
scored = model.predict_batch(user_id, cands)
# 4. 重排:打散 + 截断
result = rerank(scored, n=n)
except Exception as e:
log.exception("rec failed, fallback to hot")
result = HOT_LIST[:n] # 🛟 兜底,绝不返回空
group = "fallback"
latency = (time.time() - t0) * 1000
# 5. 埋点(生产里应该异步写 Kafka)
log.info({"user_id": user_id, "group": group, "items": result,
"latency_ms": round(latency, 2),
"avg_score": float(np.mean([s for _, s in scored])) if result else None})
return {"user_id": user_id, "items": result, "group": group,
"latency_ms": round(latency, 2)}
@app.get("/health")
def health():
return {"status": "ok"}
✅ 通关标准
- 服务 P99 延迟 < 100ms(本地小数据)
- 杀掉模型服务,接口仍能返回热门兜底
- 能从日志里统计出两组的指标差异
- README 里有清晰的架构图
🎁 加分项
- Docker 化 + docker-compose 一键启动
- 加一个前端页面,能点击并把反馈写回
- 定时任务:每小时用新数据重新训练并热更新模型
📦 数据集选择
| 数据集 | 规模 | 适合 | 链接 |
|---|---|---|---|
| MovieLens 100K/1M | 小 | ⭐ 项目一,快速迭代 | grouplens.org |
| MovieLens 32M | 3200 万评分 | 项目二,练性能 | ml-32m |
| Amazon Reviews 2023 | 5.7 亿评论,33 个类目 | ⭐ 项目二/三,有丰富文本和元数据 | HuggingFace |
| RetailRocket / Diginetica | 中 | 会话推荐 | Kaggle |
| Criteo / Avazu | 4500 万 / 4000 万 | CTR 预估、特征工程练习 | Kaggle |
| Taobao UserBehavior | 1 亿 | 序列推荐 | 阿里天池 |
💡 建议:项目一二用 MovieLens(快),项目三用 Amazon Reviews(有文本,能练冷启动和内容特征)。
🧰 可以直接用的库
| 库 | 用途 |
|---|---|
| implicit | ALS / BPR,快,工业级 |
| RecBole | 100+ 算法的统一框架,做对比实验神器 ⭐ |
| LightFM | 混合推荐(协同+内容),适合冷启动 |
| Faiss / hnswlib | 向量检索 |
| torch-rechub / DeepCTR-Torch | 深度推荐模型的现成实现 |
| Merlin (NVIDIA) | 端到端 GPU 推荐流水线 |
pip install implicit recbole lightfm faiss-cpu torch
⚠️ 别一上来就用 RecBole。先自己手写一遍 ItemCF 和 MF, 你需要知道框架里发生了什么,而不是调 API。框架是第二遍用的。
📝 项目 README 该写什么(简历用得上)
# 推荐系统实践
## 问题定义
数据集、规模、目标(Top-N 推荐 / CTR 预估)、评估指标
## 数据处理
- 稀疏度、长尾分布(放图)
- 切分策略:按时间切分,避免数据泄漏
## 方法
| 模型 | Recall@10 | NDCG@10 | 训练时间 |
|------|-----------|---------|---------|
| 热门推荐 | 0.052 | 0.031 | - |
| ItemCF | 0.118 | 0.076 | 12s |
| ALS | 0.145 | 0.094 | 45s |
| 双塔+排序 | 0.178 | 0.121 | 8min |
## 关键发现
- 加入热门惩罚 α=0.5 后 Coverage 从 12% → 31%,Recall 仅降 3%
- 召回负样本从"曝光未点击"改成"全库随机",Recall@200 提升 XX%
## 踩过的坑
1. 一开始随机切分数据 → Recall 虚高 3 倍,改成按时间切分后正常
2. ...
## 如何运行
...
🔑 「踩过的坑」这一节最加分。它证明你真的做过,而不是抄的教程。
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| ML 基础 05 | 项目一要先造尺子:数据切分、验证集怎么留、指标为什么会骗你,源头在这里 |
| 上线之后 02 | 项目三做完 A/B 之后必读:离线 NDCG 涨了线上却不涨,是这份教程的主线问题 |
| Kaggle 25 | 「先跑通最烂的版本再逐环替换」不是本教程的偏方,是竞赛圈打磨出来的标准 pipeline 打法 |
✅ 最终检查点
做完三个项目,你应该能回答:
- 为什么你的数据集要按时间切分?
- 召回和排序的负样本采样方式为什么不同?
- 你的系统在什么情况下会降级?降级到什么?
- 如果 Recall@200 只有 0.1,你会怎么排查?
- 你怎么知道模型 A 真的比模型 B 好?
👀 答案
- 因为随机切分会时间穿越,让模型见到未来信息,离线虚高、线上崩掉。真实场景永远是用过去预测未来。
- 因为两个阶段面对的候选分布不同。精排面对的是召回给的几百个候选,所以用「曝光未点击」当负样本;召回面对的是全库,必须全库随机采样。⭐ 训练分布要和线上实际面对的分布一致——用曝光未点击训召回,模型只学会了精排级别的细微区分,从全库里挑不出东西。
- 典型降级链路:精排超时 → 退回粗排结果;召回某一路挂了 → 其余路照常;全部挂了 → 兜底热门榜 / 编辑精选。⭐ 原则是每一层都要有比它更简单、更不容易挂的下一层,最终兜底必须是不依赖模型的静态列表。
- 按从便宜到贵的顺序排查:①候选池里到底有没有正样本(可能根本没进池子)②各路召回分别的 Recall 是多少(定位是哪一路的问题)③索引/向量是不是过期了④负采样和线上分布是否一致⑤最后才怀疑模型本身。
- 看 A/B 实验,而且要看置信区间。⚠️ 光看均值不够——提升必须超过随机波动才算数。还要确认:分流是否均匀、样本量是否达到最小可检测效应的要求、观察期是否覆盖了完整的周期(至少一周,避开新奇效应)。⭐ 离线指标涨、线上不涨是常态,以线上为准。
🛑 完成了?
你现在的水平已经超过了大部分只看不做的人。
想继续拉开差距?教程还有三个高难度挑战项目,每个都对准一个用普通数据集练不到的核心矛盾:
- ❄️ 挑战A · 新闻推荐:时效性生死时速 —— 物品生命周期只有几小时的极端冷启动
- 🌀 挑战B · 信息流模拟器 —— 亲手养出信息茧房,再治好它
- 🚀 挑战C · 迷你生成式推荐 —— 在笔记本上复现 2026 前沿
或者直接去 👉 17-面试与求职.md
✅ 检查点
- 三个项目分别练的是哪几节的内容?为什么要按这个顺序做?
- 做项目时最容易犯的错是什么?
- 为什么每一步都要和 baseline 比,而不是只看最终分数?
- 评估推荐系统时,为什么不能只看准确率类指标?
- 项目 README 里最有价值的一节是什么?
👀 答案
- ①协同过滤 + 评估(第 3–5、12 节)②工业漏斗 + 特征工程(第 6–8 节)③冷启动 + 多样性(第 11、13 节)。按这个顺序是因为后面的项目要用到前面的产出——评估框架建好了,后面每个改动才有尺子。
- 一上来就搭复杂模型。正确做法是先跑通最烂的版本(哪怕是热门推荐),拿到一条能从数据跑到指标的完整链路,再逐步替换其中一环。
- 因为没有 baseline 的分数没有意义——0.35 的 Recall@10 是好是坏,只有和「热门推荐能到多少」对比才知道。而且逐步对比才能定位是哪个改动带来的提升。
- 因为准确率高的推荐可能只是「全推热门」——它讨好了指标却损害了体验。必须同时看覆盖率、多样性、新颖度(第 11 节),才知道你是不是在养信息茧房。
- 「踩过的坑」。分数说明你会调,坑说明你懂原理——面试时后者有说服力得多。
⚡ 走神救援
⚡ 走神救援
三个递进项目:①协同过滤 + 评估框架(先把尺子造出来,后面每个改动才有意义)②工业漏斗 + 特征工程(召回→排序,体会分层的必要性)③冷启动 + 多样性(第 11、13 节的落地)。⭐ 铁律:先跑通最烂的版本(哪怕是热门推荐),拿到完整链路再逐环替换;每个改动都要和 baseline 比(0.35 的 Recall 是好是坏,只有对比才知道);别只看准确率——高准确率可能只是「全推热门」,必须同时看覆盖率/多样性/新颖度。README 里「踩过的坑」最值钱:分数说明你会调,坑说明你懂原理。⭐ 三个项目的通关判据,照着自查就行:① ItemCF 的 Recall@10 要明显高于热门推荐,ALS 又要高于 ItemCF——没做到先别怀疑算法,去查alpha(置信度)和factors(维度);② 召回 Recall@200 要 > 0.3(做得好能到 0.5+),端到端 NDCG@10 必须明显高于「只取召回结果的前 10 个」,否则你的排序层等于白写;③ 服务 P99 < 100ms,而且把模型服务杀掉,接口还得能返回热门兜底——这条才是项目三真正在考的东西,还要能从日志里统计出 A/B 两组的指标差异。⚠️ 最容易翻车的两个地方:随机切分数据会让 Recall 虚高三倍,一律按时间切;以及召回的负样本和排序的负样本采样方式不一样(召回要从全库随机采,模拟的是「和全库比」;排序只能在召回结果里采,那才是它线上真正见到的分布)——这是面试高频题,也是自己做项目时最常搞混的一步。