综合练习
这一课你将能做到
- 独立完成一个端到端项目:从 5000 条做题流水预测「用户答对当前题的概率」
- 构建只使用历史信息的时序特征(用户历史正确率、用户-主题正确率),杜绝标签泄漏
- 按时间切分训练/测试集,并用朴素基线证明模型的价值
- 把模型输出变成产品语言:一份薄弱主题推题清单
为什么要学
前面十一课每个主题都练过单点,但真实工作里没有「给好了 X 和 y」这回事。综合练习的目的是把数据构造、防泄漏特征、诚实评估、业务输出串成一条线——这也是面试深挖项目时的考察方式:任何一步说不出为什么,整段经历都会被质疑。
这个项目刻意用本站形态的数据:用户流水、题目难度、掌握度。做完它,你就拥有了一个可以写进简历、可以当面讲 20 分钟的完整案例。
先建立图像
项目流水线(每一步都对应下面的步骤):
模拟做题流水(5000条)
→ 时序特征(只用历史:cumcount / cumsum-shift)
→ 按时间切分(前 80% 训练,后 20% 测试)
→ HistGradientBoosting(原生吃 NaN)
→ 评估(AUC + 朴素基线对照)
→ 产品输出(user-topic 掌握度 < 0.6 → 推题清单)
跟着做
步骤 1:模拟 5000 条做题流水
import numpy as np
import pandas as pd
rng = np.random.default_rng(7)
n_users, n = 300, 5000
users = pd.DataFrame({"user_id": range(n_users),
"ability": rng.normal(0, 1, n_users)})
topics = pd.DataFrame({"topic": ["数组", "哈希表", "二叉树", "动态规划"],
"diff": [-0.3, 0.0, 0.4, 0.9]})
df = pd.DataFrame({
"user_id": rng.integers(0, n_users, n),
"topic": rng.choice(topics.topic, n, p=[0.4, 0.3, 0.2, 0.1]),
"ts": rng.integers(0, 60 * 24 * 3600, n), # 60 天内的时间戳
})
df = df.merge(users, on="user_id").merge(topics, on="topic")
p = 1 / (1 + np.exp(-(df.ability - df.diff))) # 能力-难度 → 答对概率
df["correct"] = rng.binomial(1, p)
df = df.sort_values("ts").reset_index(drop=True) # 一切按时间来
步骤 2:构造「只用历史」的特征(本项目灵魂)
g = df.groupby("user_id")["correct"]
df["user_past_n"] = g.cumcount() # 之前做过几题
df["user_past_acc"] = (g.transform("cumsum") - df["correct"]) \
/ df["user_past_n"].replace(0, np.nan)
g2 = df.groupby(["user_id", "topic"])["correct"]
df["topic_n"] = g2.cumcount()
df["topic_acc"] = (g2.transform("cumsum") - df["correct"]) \
/ df["topic_n"].replace(0, np.nan)
feats = df[["user_past_n", "user_past_acc", "topic_n", "topic_acc"]].astype(float)
label = df["correct"]
cumsum 含当前行,必须减掉 correct 才是「之前的累计」;首题的 n=0,除出 NaN——这个 NaN 必须保留,它是「没有历史」的诚实表达。
步骤 3:按时间切分,不许随机
cut = df["ts"].quantile(0.8)
tr, te = df["ts"] < cut, df["ts"] >= cut
print(f"训练 {tr.sum()} 条,测试 {te.sum()} 条")
随机切分会让同一用户「过去的记录」训练、「未来的记录」测试——模型等于提前看到了这个人的答案分布,AUC 虚高,上线即翻车。
步骤 4:训练 + 双基线评估
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.metrics import roc_auc_score
model = HistGradientBoostingClassifier(max_depth=4, random_state=42)
model.fit(feats[tr], label[tr]) # 原生支持 NaN,无需填充
proba = model.predict_proba(feats[te])[:, 1]
print("全猜多数类 AUC =", round(roc_auc_score(label[te], np.full(te.sum(), label[tr].mean())), 3))
print("单特征基线 AUC =", round(roc_auc_score(label[te], feats.loc[te, "user_past_acc"].fillna(0.5)), 3))
print("模型 AUC =", round(roc_auc_score(label[te], proba), 3))
模型必须赢过两个基线才算有用;赢多少,就是你这段工程的净价值。
步骤 5:输出推题清单
latest = (df[te].assign(p=proba).sort_values("ts")
.groupby(["user_id", "topic"]).tail(1)) # 每人每主题取最新预测
rec = latest[latest.p < 0.6].sort_values("p")
print(rec[["user_id", "topic", "p"]].head(10))
print("待推送组合数:", len(rec))
import joblib
joblib.dump(model, "mastery_hgb.joblib")
p < 0.6 的含义:预测答对概率低的「用户 × 主题」组合,说明该主题薄弱,值得推送针对性练习。
步骤 6:项目自查清单
□ 每个特征都验证过「只含当前行之前的信息」
□ 测试集时间严格晚于训练集
□ 有两个朴素基线做对照
□ 指标与业务对齐(AUC 看排序,概率留给业务定阈值)
□ 工件已保存、库版本已登记
□ 冷启动有兜底:新用户 NaN → 用全站 topic 正确率填充或直接推热门题
讲清楚要点
- 防泄漏是本项目的第一主题:
cumcount数历史、cumsum - 当前值算历史累计、按ts分位数切分,三招合起来才构成「诚实的时间旅行禁令」。 - 首题 NaN 是特征不是 bug:
HistGradientBoosting对 NaN 有原生分支(自动学「缺失走哪边」),强行填 0 会把新用户当成「全错的人」。 - 基线决定说服力:全猜多数类 AUC≈0.5,单特征历史正确率可能就有 0.65——你的模型只有显著超过它才值得部署。
- 简历写法模板:「基于 5000 条做题流水,构建用户历史正确率等防泄漏时序特征,时间切分下 HistGradientBoosting 达到测试集 AUC 0.7x,输出薄弱主题推题清单并封装为 FastAPI 服务」——数字换成你的真实结果。
你可能会懵的地方
Q1:为什么时间切分比随机切分重要? 做题行为有强用户惯性。随机切分把同一用户的前后记录分进两边,等于用未来预测未来;时间切分复刻了上线时「只有历史、没有明天」的真实处境。
Q2:推送阈值 0.6 怎么定? 它是业务参数不是模型参数:先看预测概率分布,再按代价试(推多了伤体验,推少了没感知),用推送后的正确率回升幅度做 A/B 验证。
Q3:新用户没有任何历史怎么办? 三层兜底:特征层留 NaN 让模型走缺失分支;策略层用全站 topic 先验正确率代替;产品层直接推该主题的入门题收集首题数据。
Q4:标签为什么用 correct 而不是「已掌握」? correct 在答题当下即可得,标签无滞后;掌握度本质是「未来答对的概率」,用 correct 训练的概率模型正是对它的直接估计。
Q5:简历里怎么讲这个项目? 按「背景 → 数据规模 → 关键技术决策(防泄漏/时间切分/基线对照) → 量化结果 → 部署闭环」五段讲,突出决策理由而不是 API 名词。
错法对照
| 错法 | 后果 | 正确做法 |
|---|---|---|
| 随机 train_test_split | 未来信息泄漏,AUC 虚高 | 按 ts 分位数切分 |
| cumsum 忘减当前 correct | 特征里混进标签,AUC 近乎完美 | transform("cumsum") - correct |
| 首题 NaN 填成 0 | 新用户被当成全错 | 保留 NaN 或填全站先验 |
| 只报模型 AUC 无基线 | 说服力为零 | 多数类 + 单特征双基线 |
| 特征逻辑散在 notebook | 推理端无法复刻 | 特征构造函数化并入库 |
卡点急救
- AUC 高得离谱(> 0.95)→ 九成是泄漏:逐个特征问「这一行算的时候,未来的数据碰到了吗」。
- feats 与 tr/te 报索引不对齐 →
sort_values之后立刻reset_index(drop=True),全程保持同一索引。 - HistGradientBoosting 不认 NaN → 确认 sklearn ≥ 0.21 且特征列全为数值型。
面试常问
Q1:时序场景怎么防止特征泄漏? 三条纪律:特征只聚合当前时刻之前的数据(cumcount/cumsum-shift 实现);训练测试按时间切分;上线前用「训练代码 = 服务代码」同一套特征函数,避免口径分叉。
Q2:掌握度预测的特征和标签分别怎么设计? 标签取当场可得的 correct;特征用用户历史正确率/作答量、用户×主题正确率、主题全站难度,全部按历史窗口聚合,缺失即 NaN 交给模型分支。
Q3:冷启动用户的推荐策略? 无历史时用主题先验 + 入门题探测:先推全站难度低的题收集信号,积累 3-5 条记录后再切模型预测,前后用同一套特征函数保证一致。
自测三问
cumcount()和transform("cumsum")的区别?(前者数「之前有几行」,后者累计「含当前行的和」,做历史特征要减掉当前值)- 为什么首题的 user_past_acc 必须是 NaN?(0 意味着「做过且全错」,是假信息;NaN 才是「没有历史」)
- 测试集 AUC 0.97 应该先怀疑什么?(特征泄漏,而非模型优秀)
小结一句话
端到端项目的含金量不在模型多新,而在「没有一处泄漏、每个决策都能向业务解释清楚」。
延伸
- 官方文档:scikit-learn 集成方法
- 结课寄语:恭喜完成全部 12 课。下一步用真实数据复刻这个项目、去 Kaggle 打一场时序赛,或在 GitHub 上把本项目补上测试与文档——技能的最后一公里是重复与输出。