📑 本页目录(点开跳转)
挑战项目 A · 新闻推荐:时效性生死时速
⏱ 5–7 天 | 难度 ★★★★☆ | 前置:第 1–13 节 + 项目一、二
🎯 为什么选这个项目(它的「特点」)
推荐系统里最极端的场景之一:新闻的生命周期只有几个小时。
电影推荐:《肖申克的救赎》三十年后还在被推荐
商品推荐:一件 T 恤能卖一整季
新闻推荐:一条新闻 24 小时后就是废纸 💀
这意味着:
① 物品冷启动不是"边缘情况",是"每时每刻的常态"
—— 每天几万条新新闻,全都没有历史行为
② ID Embedding 几乎没用
—— 等你学好一条新闻的 Embedding,它已经过期了
③ 内容理解(文本)成了主战场
④ 时间衰减必须显式建模
做完这个项目,你会真正理解:为什么第 13 节说物品冷启动最难,以及内容特征为什么是解药。这是拿其他任何数据集都练不到的。
📦 数据集:MIND(微软新闻数据集)
为什么它特别适合:MIND 提供了真实的曝光日志(impression logs)——每条记录都是「给用户展示了哪几条新闻、TA 点了哪条、没点哪条」。
这解决了教程里反复强调的一个问题:
✅ 真实的"曝光未点击"负样本(第 3 节:不用自己瞎猜负样本)
✅ 评估无需负采样(第 9 节:在真实曝光列表上排序即可)
- MIND-small:5 万用户,可以在笔记本上跑 ⭐ 从这个开始
- MIND-large:100 万用户,进阶用
- 下载:https://msnews.github.io/(免费,需同意协议)
数据格式:
news.tsv: 新闻ID, 类目, 子类目, 标题, 摘要, 实体...
behaviors.tsv: 曝光ID, 用户ID, 时间, 点击历史, 曝光列表
↑
"N4531-1 N7098-0 N2134-0" = 展示了3条,点了第1条
🧠 ADHD 任务切分(每块 ≤ 2 小时,按顺序打勾)
第一阶段:先跑通最烂版本(Day 1–2)
- [ ] T1 (60min) 下载 MIND-small,解析
news.tsv和behaviors.tsv,统计:新闻数、平均生命周期(首次曝光到最后曝光的时长)、每条曝光列表的平均长度 - [ ] T2 (45min) 建立评估框架:对每条 impression,给列表里的新闻打分排序,算 AUC / MRR / NDCG@5 / NDCG@10(MIND 官方指标)
- [ ] T3 (60min) Baseline 1:全局热度(近 1 小时被点次数)。⚠️ 只能用「该 impression 时间点之前」的点击数——这是本项目第一个时间陷阱
- [ ] T4 (90min) Baseline 2:类目匹配——用户历史点击的类目分布 · 候选新闻类目的匹配分
- [ ] T5 (30min) 记录两个 baseline 的指标。从现在起每个改动都和它们比。
第二阶段:内容编码器(Day 3–4)
- [ ] T6 (90min) 新闻编码器 v1:标题 TF-IDF → SVD 降到 64 维。用户向量 = 点击历史新闻向量的平均。余弦打分
- [ ] T7 (60min) 对比 T6 和 baseline —— 内容特征应该已经明显赢了热度
- [ ] T8 (120min) 新闻编码器 v2(NRMS 风格):词 Embedding → 自注意力 → 注意力池化,得到新闻向量
- [ ] T9 (90min) 用户编码器:对点击历史的新闻向量再做一层自注意力 + 注意力池化(而不是简单平均)——这就是第 8 节 DIN 思想在新闻场景的变体
第三阶段:时间建模(Day 5)—— 本项目的灵魂
- [ ] T10 (60min) 加新闻年龄特征:
log1p(曝光时刻 - 发布时刻),分桶后 Embedding(回忆第 8 节 YouTube 的 Example Age) - [ ] T11 (60min) 加热度衰减:用指数衰减的点击计数
pop = Σ exp(-λ·Δt)替代原始计数,λ 调到半衰期 ≈ 2–6 小时 - [ ] T12 (60min) 消融实验:去掉时间特征,指标掉多少?把这个数字写进 README——它是这个项目的核心发现
第四阶段:完整模型 + 报告(Day 6–7)
- [ ] T13 (120min) 端到端训练:impression 内做 softmax(点了的 vs 没点的,天然的 listwise 训练),这比逐条 BCE 更贴合排序目标
- [ ] T14 (60min) 冷启动专项评估:只看「训练集里从未出现过的新闻」的 AUC ——这是本项目区别于普通推荐项目的招牌指标
- [ ] T15 (90min) 写 README:指标演进表、时间特征消融、冷启动对比、踩坑记录
🔨 关键代码骨架
Impression 级评估(MIND 官方口径)
import numpy as np
def evaluate_impression(labels, scores):
"""labels: [1,0,0,1,...] 该次曝光的真实点击;scores: 模型打分"""
order = np.argsort(-np.asarray(scores))
y = np.asarray(labels)[order]
# AUC(该 impression 内)
n_pos, n_neg = y.sum(), (1 - y).sum()
if n_pos == 0 or n_neg == 0:
return None
rank_sum = np.where(y == 1)[0].sum()
auc = 1 - rank_sum / (n_pos * n_neg) + (n_pos - 1) / (2 * n_neg)
mrr = 1.0 / (np.where(y == 1)[0][0] + 1)
def ndcg(k):
dcg = (y[:k] / np.log2(np.arange(2, min(k, len(y)) + 2))).sum()
ideal = np.sort(y)[::-1]
idcg = (ideal[:k] / np.log2(np.arange(2, min(k, len(y)) + 2))).sum()
return dcg / idcg if idcg > 0 else 0.0
return {"auc": auc, "mrr": mrr, "ndcg5": ndcg(5), "ndcg10": ndcg(10)}
NRMS 风格的新闻/用户编码器
import torch, torch.nn as nn
class AttnPool(nn.Module):
"""注意力池化:一组向量 → 一个向量(学到"哪些词/哪些历史更重要")"""
def __init__(self, d, hidden=128):
super().__init__()
self.proj = nn.Sequential(nn.Linear(d, hidden), nn.Tanh(), nn.Linear(hidden, 1))
def forward(self, x, mask=None): # x: (B, L, D)
w = self.proj(x).squeeze(-1) # (B, L)
if mask is not None:
w = w.masked_fill(mask == 0, -1e9)
w = torch.softmax(w, dim=-1)
return torch.bmm(w.unsqueeze(1), x).squeeze(1) # (B, D)
class NewsEncoder(nn.Module):
"""标题词序列 → 新闻向量"""
def __init__(self, vocab, d=128, heads=8):
super().__init__()
self.emb = nn.Embedding(vocab, d, padding_idx=0)
self.mha = nn.MultiheadAttention(d, heads, batch_first=True)
self.pool = AttnPool(d)
def forward(self, title_ids): # (B, L)
x = self.emb(title_ids)
mask = title_ids != 0
h, _ = self.mha(x, x, x, key_padding_mask=~mask)
return self.pool(h, mask) # (B, D)
class UserEncoder(nn.Module):
"""点击历史的新闻向量序列 → 用户向量(对'历史'再做一次注意力)"""
def __init__(self, d=128, heads=8):
super().__init__()
self.mha = nn.MultiheadAttention(d, heads, batch_first=True)
self.pool = AttnPool(d)
def forward(self, hist_vecs, hist_mask): # (B, H, D)
h, _ = self.mha(hist_vecs, hist_vecs, hist_vecs,
key_padding_mask=~hist_mask.bool())
return self.pool(h, hist_mask)
# 打分 = 用户向量 · 新闻向量 + w_age · 新闻年龄桶Embedding的贡献
# 训练 = impression 内 softmax 交叉熵(点击的那条是正类)
时间衰减热度(避开未来信息的写法)
import math
from collections import defaultdict
class DecayPopularity:
"""流式维护每条新闻的衰减热度。必须按时间顺序喂数据!"""
def __init__(self, half_life_hours=4.0):
self.lam = math.log(2) / (half_life_hours * 3600)
self.score = defaultdict(float)
self.last_ts = defaultdict(float)
def update(self, news_id, ts): # 每次点击时调用
dt = ts - self.last_ts[news_id]
self.score[news_id] = self.score[news_id] * math.exp(-self.lam * dt) + 1.0
self.last_ts[news_id] = ts
def get(self, news_id, ts): # 打分时调用(只用过去的信息)
dt = ts - self.last_ts[news_id]
return self.score[news_id] * math.exp(-self.lam * max(dt, 0))
🕳️ 这个项目专属的坑(提前剧透)
| 坑 | 症状 | 解法 |
|---|---|---|
| 热度特征穿越 ⭐ | 热度 baseline AUC 高得离谱 | 热度必须流式计算,只用 impression 时刻之前的点击 |
| 按用户切分训练/测试 | 指标虚高 | MIND 的正确做法是按时间切(官方已按天切好) |
| 标题词表爆炸 | Embedding 巨大、低频词学不好 | 词频 < 3 的映射到 <UNK> |
| 历史长度悬殊 | 新用户 0 条历史,重度用户 500 条 | 截断到最近 50 条 + padding + mask;0 历史用户单独看指标 |
| impression 内全负 | AUC 算不了 | 跳过(评估代码里已处理) |
| 半衰期 λ 乱设 | 热度特征没用 | 先统计真实数据里"点击量随新闻年龄的衰减曲线",再定半衰期 |
✅ 通关标准(Definition of Done)
- NRMS 风格模型的 AUC > 0.66(MIND-small 上的合理水平;热度 baseline 约 0.60)
- 时间特征消融实验有明确数字(预期:去掉后 AUC 掉 1–3 个点)
- 冷新闻(训练集未见过)的 AUC 与整体 AUC 差距 < 3 个点 ——证明你的内容编码器真的解决了冷启动
- README 有指标演进表:热度 → 类目 → TF-IDF → NRMS → +时间特征
🏆 拉开差距的加分项
- 用预训练语言模型(如
sentence-transformers的多语言模型)替换词级编码器,对比效果与推理耗时 - 实现两阶段:衰减热度+类目做召回(每次 impression 从全库召 200),NRMS 做精排——观察召回漏斗损失
- 分析茧房效应:统计用户点击类目熵随时间的变化,模型是不是在让用户越看越窄?(呼应第 11 节)
⚡ 走神救援
我在做新闻推荐(MIND 数据集):新闻生命周期只有几小时 → ID Embedding 无用 → 内容编码器(NRMS:词注意力→新闻向量→历史注意力→用户向量)是主战场 + 时间衰减热度(流式算,防穿越)。招牌指标:冷新闻 AUC。训练用 impression 内 softmax。
下一个挑战 👉 20-挑战项目B-信息流模拟器-亲手养出信息茧房.md
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| 上线之后 06 | 新闻的「时效性」本质就是概念漂移的极端形态——别人几个月漂一次,你几小时漂一次 |
| 上线之后 16 | 这个项目绕不开的问题:多久重训一次、增量还是全量、怎么判断该训了 |
| 强化学习 07 | 新稿件没有任何行为数据就要决定给不给量——这是纯粹的冷启动探索问题 |
✅ 检查点
- 新闻推荐和电影/商品推荐最根本的区别是什么?它导致了哪四个后果?
- 为什么 ID Embedding 在新闻场景几乎没用?
- MIND 数据集特别适合做这个项目的原因是什么?
- T3 的「第一个时间陷阱」是什么?
- 第三阶段(时间建模)里的两个核心特征是什么?
- T14 的「冷启动专项评估」具体怎么做?为什么它是招牌指标?
- 为什么 impression 内做 softmax 比逐条 BCE 更好?
👀 答案
- 新闻的生命周期只有几个小时(电影三十年后还在被推荐)。四个后果:①物品冷启动是常态不是边缘情况 ②ID Embedding 几乎没用 ③内容理解成主战场 ④时间衰减必须显式建模。
- 因为等你学好一条新闻的 Embedding,它已经过期了——ID Embedding 需要足够的行为数据才能训好,而新闻根本活不到那个时候。
- 它提供真实的曝光日志——每条记录是「展示了哪几条、点了哪条、没点哪条」。这直接解决了两个问题:有真实的「曝光未点击」负样本(不用自己瞎猜),评估无需负采样(在真实曝光列表上排序即可)。
- 算全局热度时只能用该 impression 时间点之前的点击数。用了之后的数据就是用未来预测过去——这是最经典的时间泄漏。
- ①新闻年龄:log1p(曝光时刻 − 发布时刻) 分桶后 Embedding(YouTube 的 Example Age 思想)②热度衰减:用指数衰减的点击计数 pop = Σexp(−λΔt) 替代原始计数,半衰期调到 2–6 小时。
- 只看「训练集里从未出现过的新闻」的 AUC。它是招牌指标是因为这正是新闻场景最难也最重要的部分——普通推荐项目的整体指标会被老物品的表现掩盖掉冷启动的真实能力。
- 因为 impression 天然就是一个候选列表(点了的 vs 没点的),在列表内做 softmax 是 listwise 训练,直接对齐排序目标;逐条 BCE 是 pointwise,优化的是绝对概率而不是相对顺序。
🛑 可以停在这里
⚡ 走神救援
新闻推荐的极端点:物品生命周期只有几小时 → ①物品冷启动是每时每刻的常态②ID Embedding 几乎没用(等你学好它已经过期)③内容理解成主战场 ④时间衰减必须显式建模。用 MIND 数据集(有真实曝光日志 → 真实的「曝光未点击」负样本 + 评估无需负采样)。四阶段:①先跑通最烂版本(热度 + 类目匹配两个 baseline)——⚠️第一个时间陷阱:算热度只能用该 impression 之前的点击数②内容编码器(TF-IDF+SVD → NRMS 风格自注意力;用户编码器对历史再做一层注意力池化 = DIN 思想的新闻版)③⭐时间建模是灵魂:新闻年龄(log1p 分桶 Embedding)+ 热度指数衰减(半衰期 2–6 小时);做消融实验,去掉时间特征掉多少分就是这个项目的核心发现④端到端训练用 impression 内 softmax(listwise,直接对齐排序目标);⭐招牌指标是只看训练集里从未出现过的新闻的 AUC。