🏠 总目录📚 本教程 挑战B · 信息流模拟器
📑 本页目录(点开跳转)

挑战项目 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)

第二阶段:放进一个"贪婪"的推荐系统(Day 3)

第三阶段:观察与解剖(Day 4)—— 项目的高光时刻

第四阶段:治疗(Day 5–6)

第五阶段:报告(Day 7)


🔨 主循环骨架

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)

  1. 四条策略的 30 天曲线图(日活/CTR/类目熵/满意度,至少 3 个种子平均)
  2. 复现出教科书现象:贪婪策略短期 CTR 最高、30 天日活最低
  3. 至少一种"治疗组合"做到:30 天日活 ≥ 随机策略,且 CTR ≥ 贪婪策略的 85%
  4. 一张用户级茧房热力图(天 × 类目)
  5. README 有病理报告 + 疗法对比

🏆 拉开差距的加分项


走神救援

我在造模拟世界:SimUser 有真实兴趣+疲劳+满意度(含多样性项)+流失。主循环:推荐→点击→日志回流→每晚重训(反馈循环)。已知剧本:贪婪策略 CTR 先涨、类目熵崩、20 天后日活崩。疗法:ε-greedy / Thompson / MMR。铁律:世界参数冻结后只改策略侧;每种策略跑 5 个种子。

下一个挑战 👉 21-挑战项目C-迷你生成式推荐-复现前沿.md


🔗 这一章连到哪里

去哪为什么
上线之后 01茧房是反馈闭环跑偏的结果;那一章讲这个闭环在所有线上模型里的通用形态
上线之后 15茧房正是「短期指标涨、长期体验垮」的典型:你的模拟器就是在把这个过程加速播放
强化学习 07打破茧房在数学上就是给探索留多少预算——ε、UCB、Thompson 三种给法

✅ 检查点

  1. 这个项目为什么不用现成数据集?公开数据集缺了什么?
  2. 模拟世界要构建哪三个部分?
  3. 用户模型里的「疲劳机制」起什么作用?没有它会怎样?
  4. 你会亲眼看到的三个崩塌现象是什么?
  5. 满意度更新公式里为什么同时包含 CTR 和 variety?
  6. 两味「药」分别是什么?分别治什么?
  7. 这个项目揭示的、推荐系统和普通机器学习最本质的区别是什么?
👀 答案
  1. 因为所有公开数据集都是静态快照,你无法用它们观察「模型改变数据 → 数据改变模型」的动态过程。而这正是这个项目唯一要看的东西。
  2. 模拟世界(1000 个虚拟用户,各有真实兴趣分布 + 疲劳机制)②推荐系统(每天给用户推内容)③反馈循环(点击回流成训练数据,模型每「天」重训)。
  3. 它让「一直推同一类目」产生递减的收益。没有疲劳机制,推荐系统一直推用户最爱的类目就是全局最优解——信息茧房不会显现出代价,整个实验就没有意义了。
  4. 第 5 天起推荐多样性开始崩塌 ②第 15 天用户看到的类目从 8 个缩到 2 个 ③第 20 天模拟用户开始流失。
  5. 因为如果满意度只由 CTR 决定,那「猛推最爱的类目」就是最优策略,茧房不会带来任何惩罚。加入 variety 项,才让「看得广」也成为满意度的一部分——这对应真实世界里用户的长期留存
  6. 探索(Thompson Sampling,第 13 节)——治「系统再也不知道用户还喜欢什么」;②多样性重排(MMR/DPP,第 11 节)——治「单次列表全是同一类」。用治疗前后的 30 天留存曲线对比效果。
  7. 推荐系统会改变它自己的训练数据(闭环),而普通机器学习的数据分布是外生的、固定的。这导致了曝光偏差、反馈循环、离线评估失真等一整类普通 ML 不会遇到的问题。

🛑 可以停在这里

走神救援
不用任何现成数据集——自己造一个「世界」,因为所有公开数据集都是静态快照,无法观察「模型改变数据 → 数据改变模型」的动态过程。三部分:模拟世界(1000 个虚拟用户,真实兴趣分布 + 疲劳机制)、推荐系统、反馈循环(点击回流重训)。⚠️疲劳机制是设计的关键——没有它,「一直推最爱的类目」就是全局最优,茧房不会显现代价;同理满意度公式要同时含 CTR 和 variety,否则猛推单一类目不会受罚。你会亲眼看到:第5天多样性开始崩塌、第15天类目从8个缩到2个、第20天用户开始流失。两味药:探索(Thompson Sampling)治「系统不知道用户还喜欢什么」、多样性重排(MMR/DPP)治「单次列表全同类」,用30天留存曲线对比。⭐ 这个项目揭示的本质区别:推荐系统会改变它自己的训练数据——曝光偏差、反馈循环、离线评估失真全都源于此。

打卡记录保存在你的浏览器里,首页能看到总进度