📑 本页目录(点开跳转)
挑战项目 B · 信息流模拟器:亲手养出一个信息茧房,再治好它
⏱ 5–7 天 | 难度 ★★★★★ | 前置:第 1–13 节(重点 11、12、13)
🎯 为什么选这个项目(它的「特点」)
这是三个挑战里最独特的一个:你不用任何现成数据集,而是自己造一个「世界」——
你要构建:
① 一个模拟世界:1000 个虚拟用户,各有真实兴趣分布 + 疲劳机制
② 一个推荐系统:在这个世界里每天给用户推内容
③ 一个反馈循环:用户的点击回流成训练数据,模型每"天"重训
然后你会亲眼看到(用自己的图表):
💀 第 5 天起,推荐多样性开始崩塌
💀 第 15 天,用户看到的类目从 8 个缩到 2 个
💀 第 20 天,模拟用户开始"流失"
最后你要当医生:
💊 加探索(Thompson Sampling)、加多样性(MMR/DPP)
💊 对比治疗前后的"30 天留存曲线"
为什么这个练习无可替代:所有公开数据集都是静态快照,你永远无法用它们观察「模型改变数据 → 数据改变模型」的动态过程。而这个动态过程恰恰是推荐系统和普通机器学习最本质的区别(第 2 节的闭环、第 12 节的曝光偏差、第 13 节的反馈循环——全都只能在这里"眼见为实")。
📌 这也是学术界的真实研究工具:Google 开源过 RecSim,此类模拟器被广泛用于研究反馈循环和 RL 推荐。你在造一个属于自己的迷你版。
🌍 模拟世界的设计(核心!花时间想清楚)
用户模型
import numpy as np
N_TOPICS = 8 # 8 个内容类目:科技/体育/娱乐/美食/游戏/时政/时尚/科普
N_USERS = 1000
N_ITEMS_PER_DAY = 200 # 每天新产生 200 条内容
class SimUser:
def __init__(self, rng):
# 真实兴趣:Dirichlet 采样 → 大多数人有 2-3 个主兴趣 + 若干弱兴趣
self.interest = rng.dirichlet(alpha=np.full(N_TOPICS, 0.7))
self.fatigue = np.zeros(N_TOPICS) # 各类目的疲劳度
self.satisfaction = 0.7 # 满意度,决定会不会流失
self.alive = True
def click_prob(self, item_topic, item_quality):
"""点击概率 = 兴趣 × 质量 × (1 - 疲劳)"""
base = self.interest[item_topic] * item_quality
return np.clip(base * (1 - self.fatigue[item_topic]), 0.001, 0.95)
def consume(self, shown_topics, clicked_mask, rng):
"""看完一屏后的状态更新——这里是模拟器的灵魂"""
# ① 点了的类目:短期兴趣小幅上升(上瘾机制)但疲劳累积
for t, c in zip(shown_topics, clicked_mask):
if c:
self.fatigue[t] = min(self.fatigue[t] + 0.08, 0.9)
else:
self.fatigue[t] = max(self.fatigue[t] - 0.02, 0)
# 没被展示的类目疲劳自然恢复
shown = set(shown_topics)
for t in range(N_TOPICS):
if t not in shown:
self.fatigue[t] = max(self.fatigue[t] - 0.05, 0)
# ② 满意度更新:既要"点得多",也要"看得广"
ctr_today = clicked_mask.mean()
variety = len(set(shown_topics)) / N_TOPICS
self.satisfaction = 0.85 * self.satisfaction + 0.15 * (0.6*ctr_today + 0.4*variety)
# ③ 流失判定:满意度太低就再见
if rng.random() < max(0, 0.25 - self.satisfaction) :
self.alive = False
设计意图拆解(这些机制各自制造一种真实现象):
| 机制 | 制造的现象 | 对应教程 |
|---|---|---|
| 疲劳随点击累积 | 「再好吃的菜连吃 10 天也腻」→ 边际效用递减 | 第 11 节 |
| 满意度含 variety 项 | 只推一个类目 → 短期 CTR 高但满意度垮 → 流失 | 第 11 节的长短期矛盾 |
| 点击概率含质量项 | 给探索策略一个能发现的「客观好内容」 | 第 13 节 |
| 流失机制 | 让「留存」成为可测量的终极指标 | 第 12 节 |
物品模型
class SimItem:
def __init__(self, item_id, day, rng):
self.id = item_id
self.topic = rng.integers(N_TOPICS)
self.quality = np.clip(rng.beta(2, 5) * 2, 0, 1) # 大多数内容平庸,少数精品
self.birth_day = day
🧠 ADHD 任务切分
第一阶段:造世界(Day 1–2)
- [ ] T1 (90min) 实现
SimUser/SimItem/ 每日主循环骨架(先用随机推荐当策略) - [ ] T2 (60min) 实现指标记录器:每天记录 日活、平均 CTR、平均满意度、展示类目熵、流失数
- [ ] T3 (60min) 跑 30 天随机策略,画 5 条曲线。这是你的对照组——随机推荐 CTR 低,但多样性满分
- [ ] T4 (30min) 健全性检查:把某类目疲劳系数调成 0,验证世界的行为符合直觉。模拟器 bug 会毁掉后面所有结论,这一步不能跳
第二阶段:放进一个"贪婪"的推荐系统(Day 3)
- [ ] T5 (90min) 实现一个简单 CTR 模型(逻辑回归或小 MLP:用户历史类目分布 × 物品类目 one-hot + 物品历史 CTR)
- [ ] T6 (60min) 接入每日循环:白天用模型推 Top-10 → 收集点击 → 晚上用累计日志重训 → 第二天用新模型
- [ ] T7 (60min) 跑 30 天,把曲线叠到对照组上。你应该看到:CTR 前 5 天猛涨 → 类目熵持续下跌 → 第 10–20 天满意度和日活开始崩
第三阶段:观察与解剖(Day 4)—— 项目的高光时刻
- [ ] T8 (60min) 挑 3 个用户个体深挖:画出 TA 30 天里被展示的类目分布热力图(天 × 类目)。茧房形成的过程会直观得吓人
- [ ] T9 (60min) 解剖训练数据:第 1 天 vs 第 20 天,训练日志里的类目分布差多少?——这就是第 12 节说的「模型在学上一版模型的偏好」
- [ ] T10 (30min) 写下你的「病理报告」:贪婪策略 → 头部类目曝光垄断 → 训练数据污染 → 更贪 → 疲劳+单调 → 满意度崩 → 流失
第四阶段:治疗(Day 5–6)
- [ ] T11 (60min) 疗法①:ε-greedy(10% 随机位)
- [ ] T12 (60min) 疗法②:Thompson Sampling 接管「新物品前 100 次曝光」(第 13 节的代码直接搬)
- [ ] T13 (90min) 疗法③:MMR 重排(第 11 节代码,λ 扫 0.5/0.7/0.9 三档)
- [ ] T14 (60min) 终极对比图:随机 / 贪婪 / 贪婪+探索 / 贪婪+探索+MMR 四条 30 天日活曲线放同一张图
- [ ] T15 (30min) 找出「短期 CTR 掉最少、30 天留存最高」的组合和参数
第五阶段:报告(Day 7)
- [ ] T16 (90min) README:世界设定 → 病理报告(带热力图)→ 三种疗法对比 → 结论。这份报告是面试神器——没有几个候选人能拿出「我亲手复现并治好了信息茧房」的实验
🔨 主循环骨架
def run_simulation(policy, days=30, seed=42):
rng = np.random.default_rng(seed)
users = [SimUser(rng) for _ in range(N_USERS)]
items, logs, metrics = [], [], []
for day in range(days):
# 每天新内容上架(持续的物品冷启动!)
items += [SimItem(len(items)+k, day, rng) for k in range(N_ITEMS_PER_DAY)]
fresh = [it for it in items if day - it.birth_day <= 3] # 只推 3 天内的
day_stats = {"day": day, "clicks": 0, "shows": 0,
"topic_counts": np.zeros(N_TOPICS)}
for uid, u in enumerate(users):
if not u.alive:
continue
slate = policy.select(uid, u, fresh, k=10, day=day) # 推 10 条
topics = np.array([it.topic for it in slate])
probs = np.array([u.click_prob(it.topic, it.quality) for it in slate])
clicks = rng.random(len(slate)) < probs
u.consume(topics, clicks, rng)
for it, c in zip(slate, clicks): # 日志回流
logs.append((day, uid, it.id, it.topic, int(c)))
day_stats["clicks"] += clicks.sum(); day_stats["shows"] += len(slate)
for t in topics: day_stats["topic_counts"][t] += 1
# 晚上:用累计日志重训模型(反馈循环的关键一环)
policy.retrain(logs, items)
# 记录指标
p = day_stats["topic_counts"]; p = p / max(p.sum(), 1)
entropy = -np.sum(p[p > 0] * np.log(p[p > 0])) / np.log(N_TOPICS)
alive = sum(u.alive for u in users)
metrics.append({
"day": day, "dau": alive,
"ctr": day_stats["clicks"] / max(day_stats["shows"], 1),
"topic_entropy": entropy, # ⭐ 茧房温度计
"satisfaction": np.mean([u.satisfaction for u in users if u.alive]),
})
print(metrics[-1])
return metrics, logs
# 策略接口 —— 四种策略都实现它
class Policy:
def select(self, uid, user, candidates, k, day): ...
def retrain(self, logs, items): ...
class RandomPolicy(Policy):
def __init__(self, seed=0): self.rng = np.random.default_rng(seed)
def select(self, uid, user, cands, k, day):
return list(self.rng.choice(cands, size=min(k, len(cands)), replace=False))
def retrain(self, logs, items): pass
🕳️ 这个项目专属的坑
| 坑 | 说明 |
|---|---|
| 模拟器本身有 bug ⭐ 最危险 | 结论全建立在世界规则上。T4 的健全性检查必须做:关掉疲劳 → 茧房还形成吗?满意度去掉 variety 项 → 流失还发生吗?每个机制都要能单独验证 |
| 调参调出想要的结论 | 你有上帝之手,很容易把参数调到"故事最好看"。✅ 纪律:世界参数在 T4 后冻结,之后只准改策略侧 |
| 随机种子的运气 | 单次模拟的曲线有噪声 → 每种策略跑 5 个种子,画均值 ± 标准差带 |
| 模型学不到东西 | 特征太弱(只有类目)。可以给用户加"历史点击类目分布"特征,给物品加"衰减 CTR"特征 |
| 探索位放最后 | 探索内容全放第 10 位 → 曝光了但用户根本看不到(模拟里可加位置衰减:第 k 位的注意概率 × 0.85^k,顺便复现第 12 节的位置偏差!) |
✅ 通关标准(Definition of Done)
- 四条策略的 30 天曲线图(日活/CTR/类目熵/满意度,至少 3 个种子平均)
- 复现出教科书现象:贪婪策略短期 CTR 最高、30 天日活最低
- 至少一种"治疗组合"做到:30 天日活 ≥ 随机策略,且 CTR ≥ 贪婪策略的 85%
- 一张用户级茧房热力图(天 × 类目)
- README 有病理报告 + 疗法对比
🏆 拉开差距的加分项
- 加位置偏差进模拟(注意概率随位置衰减),然后验证第 12 节的「位置特征训练法」真的有效——你可以直接测量真实兴趣(上帝视角),这是真实世界做不到的
- 实现 DPP 重排替代 MMR,对比
- 把「满意度」换成延迟奖励,试一个最简单的 bandit-over-slates:奖励 = 当天满意度变化量——这就摸到强化学习推荐的门了(第 18 节专题五)
- 做一个曝光偏差实验:只用贪婪策略的日志离线评估随机策略,看离线指标错得多离谱(复现第 12 节陷阱 3)
⚡ 走神救援
我在造模拟世界:SimUser 有真实兴趣+疲劳+满意度(含多样性项)+流失。主循环:推荐→点击→日志回流→每晚重训(反馈循环)。已知剧本:贪婪策略 CTR 先涨、类目熵崩、20 天后日活崩。疗法:ε-greedy / Thompson / MMR。铁律:世界参数冻结后只改策略侧;每种策略跑 5 个种子。
下一个挑战 👉 21-挑战项目C-迷你生成式推荐-复现前沿.md
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| 上线之后 01 | 茧房是反馈闭环跑偏的结果;那一章讲这个闭环在所有线上模型里的通用形态 |
| 上线之后 15 | 茧房正是「短期指标涨、长期体验垮」的典型:你的模拟器就是在把这个过程加速播放 |
| 强化学习 07 | 打破茧房在数学上就是给探索留多少预算——ε、UCB、Thompson 三种给法 |
✅ 检查点
- 这个项目为什么不用现成数据集?公开数据集缺了什么?
- 模拟世界要构建哪三个部分?
- 用户模型里的「疲劳机制」起什么作用?没有它会怎样?
- 你会亲眼看到的三个崩塌现象是什么?
- 满意度更新公式里为什么同时包含 CTR 和 variety?
- 两味「药」分别是什么?分别治什么?
- 这个项目揭示的、推荐系统和普通机器学习最本质的区别是什么?
👀 答案
- 因为所有公开数据集都是静态快照,你无法用它们观察「模型改变数据 → 数据改变模型」的动态过程。而这正是这个项目唯一要看的东西。
- ①模拟世界(1000 个虚拟用户,各有真实兴趣分布 + 疲劳机制)②推荐系统(每天给用户推内容)③反馈循环(点击回流成训练数据,模型每「天」重训)。
- 它让「一直推同一类目」产生递减的收益。没有疲劳机制,推荐系统一直推用户最爱的类目就是全局最优解——信息茧房不会显现出代价,整个实验就没有意义了。
- ①第 5 天起推荐多样性开始崩塌 ②第 15 天用户看到的类目从 8 个缩到 2 个 ③第 20 天模拟用户开始流失。
- 因为如果满意度只由 CTR 决定,那「猛推最爱的类目」就是最优策略,茧房不会带来任何惩罚。加入 variety 项,才让「看得广」也成为满意度的一部分——这对应真实世界里用户的长期留存。
- ①探索(Thompson Sampling,第 13 节)——治「系统再也不知道用户还喜欢什么」;②多样性重排(MMR/DPP,第 11 节)——治「单次列表全是同一类」。用治疗前后的 30 天留存曲线对比效果。
- 推荐系统会改变它自己的训练数据(闭环),而普通机器学习的数据分布是外生的、固定的。这导致了曝光偏差、反馈循环、离线评估失真等一整类普通 ML 不会遇到的问题。
🛑 可以停在这里
⚡ 走神救援
⭐不用任何现成数据集——自己造一个「世界」,因为所有公开数据集都是静态快照,无法观察「模型改变数据 → 数据改变模型」的动态过程。三部分:模拟世界(1000 个虚拟用户,真实兴趣分布 + 疲劳机制)、推荐系统、反馈循环(点击回流重训)。⚠️疲劳机制是设计的关键——没有它,「一直推最爱的类目」就是全局最优,茧房不会显现代价;同理满意度公式要同时含 CTR 和 variety,否则猛推单一类目不会受罚。你会亲眼看到:第5天多样性开始崩塌、第15天类目从8个缩到2个、第20天用户开始流失。两味药:探索(Thompson Sampling)治「系统不知道用户还喜欢什么」、多样性重排(MMR/DPP)治「单次列表全同类」,用30天留存曲线对比。⭐ 这个项目揭示的本质区别:推荐系统会改变它自己的训练数据——曝光偏差、反馈循环、离线评估失真全都源于此。