"""
=============================================================================
第 14 讲 · 代码 4：代理指标 —— 优化点击率，还是优化满意度？
=============================================================================

对应讲义：lectures/lecture14_style_surrogates.md 第 5 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
推荐系统真正想要的是"用户满意"，但满意度很难测（要发问卷，大多数人不填）；点击很容易测，于是被当作代理指标。
可是"标题党"内容点击率高、看完却让人失望。

模拟：50 个内容，每个有"真实质量"q 和"标题党程度"c。
    · 点击概率 = σ(−1 + 1.5 q + 2.5 c)            —— 标题党很能吸引点击
    · 点开之后的满意度 = q − 0.8 c + 噪声         —— 标题党让人失望
    · 只有 10% 的点击之后用户会填满意度问卷（数据稀疏）

用 UCB（第 11 讲）在 2 万轮里决定推荐哪个内容，比较三种"奖励"：
    · 只看点击（代理指标）
    · 只看问卷里的满意度（真正的目标，但数据稀疏）
    · 点击 + λ × 问卷满意度（混合）

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 04_engagement_proxy.py        # 约 10 秒

只需要 numpy。依赖本目录的 style_tools.py（只用打印工具）。
"""

import math

import numpy as np

from style_tools import pad, print_header, sigmoid

K, T, SURVEY = 50, 20000, 0.1


def make_items(rng):
    """50 个内容的真实质量 q 和标题党程度 c（c 和 q 有一点负相关：好内容很少靠标题党）。"""
    q = rng.normal(0, 1, K)
    c = np.clip(0.5 - 0.2 * q + rng.normal(0, 0.5, K), 0, None)
    return q, c


def run(objective, q, c, rng, lam=1.0, coef=1.0):
    """
    用 UCB 推荐 T 轮。

    【参数】objective："click"（奖励 = 是否点击）、"survey"（奖励 = 问卷满意度，没填问卷时没有奖励信号）、
        "mixed"（奖励 = 点击 + lam × 问卷满意度）
    【返回】dict：点击率、每次推荐带来的平均满意度（没点击算 0）、最常推荐的内容的 (q, c)
    【思路】每个内容维护"奖励之和"和"有奖励信号的次数"；UCB 指数 = 平均奖励 + coef·sqrt(2 ln t / n)。
        survey 目标下，只有填了问卷才算一次观测 —— 数据少 10 倍，学得慢。
    """
    p_click = sigmoid(-1 + 1.5 * q + 2.5 * c)
    sum_r, n_r = np.zeros(K), np.zeros(K)
    clicks = sat_total = 0.0
    shown = np.zeros(K)
    for t in range(T):
        if np.any(n_r == 0):
            k = int(np.flatnonzero(n_r == 0)[0])
        else:
            k = int(np.argmax(sum_r / n_r + coef * np.sqrt(2 * math.log(t + 1) / n_r)))
        shown[k] += 1
        click = rng.random() < p_click[k]
        sat = q[k] - 0.8 * c[k] + 0.5 * rng.normal() if click else 0.0
        clicks += click
        sat_total += sat
        surveyed = click and rng.random() < SURVEY
        if objective == "click":
            sum_r[k] += click
            n_r[k] += 1
        elif objective == "survey":
            if surveyed:
                sum_r[k] += sat
                n_r[k] += 1
            elif not click:                       # 没点击：满意度视为 0（也是一个信号）
                n_r[k] += 1
        else:
            sum_r[k] += click + (lam * sat / SURVEY if surveyed else 0.0)    # 按填写率放大，保持期望不变
            n_r[k] += 1
    top = int(np.argmax(shown))
    return dict(ctr=clicks / T, sat=sat_total / T, top_q=q[top], top_c=c[top])


def main():
    rng = np.random.default_rng(144)
    q, c = make_items(rng)
    p_click = sigmoid(-1 + 1.5 * q + 2.5 * c)
    value = p_click * (q - 0.8 * c)                # 推荐一次的期望满意度
    best_ctr, best_sat = int(np.argmax(p_click)), int(np.argmax(value))
    print_header("优化点击率，还是优化满意度？")
    print(f"{K} 个内容。点击率最高的内容：质量 {q[best_ctr]:+.2f}、标题党程度 {c[best_ctr]:.2f}、点击率 {p_click[best_ctr]:.2f}、"
          f"每次推荐的期望满意度 {value[best_ctr]:+.2f}")
    print(f"        期望满意度最高的内容：质量 {q[best_sat]:+.2f}、标题党程度 {c[best_sat]:.2f}、点击率 {p_click[best_sat]:.2f}、"
          f"每次推荐的期望满意度 {value[best_sat]:+.2f}")
    print(f"\n用 UCB 推荐 {T} 轮（10 次实验平均）。只有 {SURVEY:.0%} 的点击之后有问卷：\n")
    print(f"  {pad('优化的目标', 26)} {pad('点击率', 7, 'right')} {pad('每次推荐的满意度', 17, 'right')} "
          f"{pad('最常推荐的内容：质量 / 标题党', 30, 'right')}")
    for name, obj, kw in [("只看点击（代理）", "click", {}), ("只看问卷满意度", "survey", {}),
                          ("点击 + 1 × 问卷满意度", "mixed", {"lam": 1.0}), ("点击 + 3 × 问卷满意度", "mixed", {"lam": 3.0})]:
        res = [run(obj, q, c, np.random.default_rng(10 + r), **kw) for r in range(10)]
        ctr = np.mean([x["ctr"] for x in res])
        sat = np.mean([x["sat"] for x in res])
        tq = np.mean([x["top_q"] for x in res])
        tc = np.mean([x["top_c"] for x in res])
        print(f"  {pad(name, 26)} {ctr:>7.3f} {sat:>+17.3f} {pad(f'{tq:+.2f} / {tc:.2f}', 30, 'right')}")
    print("""
观察：
  · 四种做法的点击率几乎一样（0.85 左右），只看点击率，看不出它们有什么区别。
  · 可满意度差了很多：只看点击的系统学会了推荐标题党（最常推荐的内容标题党程度接近 1），
    每次推荐的满意度只有约 0.18，而最好的内容能到 1.49。这就是“参与度”作为代理指标的典型失败
    （极端型 + 对抗型古德哈特：内容创作者也会迎合点击）。
  · 只看问卷满意度：目标对了，但有奖励信号的数据少 10 倍，2 万轮里还没完全收敛。
  · 混合（λ = 1）最好：用大量的点击数据快速排除没人点的内容，再用少量的问卷数据纠正“标题党”的方向。
  · λ = 3 反而变差：问卷满意度要按填写率放大（×10）才能和点击的量级可比，权重再乘 3，奖励的噪声太大，
    UCB 学得慢。代理和真实目标怎样加权，本身是一个要调的权衡。
  · 一些平台公开描述过类似的做法：把问卷得到的“满意度”“是否值得花时间”加进排序目标，而不只看观看时长和点击。
  · 和医学里的“替代终点”是同一个教训（讲义第 5 节）：一个容易测、和目标相关的指标，被直接优化时可能和目标背道而驰。""")


if __name__ == "__main__":
    main()
