"""
=============================================================================
第 4 讲 · 代码 2：K 维因子模型 —— 从流行度到个性化（矩阵分解）
=============================================================================

对应讲义：lectures/lecture04_factor_models.md 第 3 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
写作助手现在有 40 个风格各异的回答模板、200 个用户。每个用户只看过其中一部分，对看过的
点了"有用"（1）或"没用"（0）。这是一个**大部分格子是空的**"用户 × 回答"矩阵 ——
和 Netflix Prize 的"用户 × 电影"评分矩阵是同一类问题。

真实的世界（上帝视角）：每个回答有两个隐藏的风格维度（详细程度、正式程度）和一个质量；
每个用户有自己的胃口和对两个风格维度的口味。

        logit_ij = a_i + 2·质量_j + 口味_i · 风格_j

    演示 1：几种模型的比较 —— 只看流行度、Rasch（加上胃口）、K = 1, 2, 4, 8 的因子模型。
            K = 2（和真实世界一样）最好；K 太大会过拟合（要靠正则化压住）。
    演示 2：数据越多，个性化越值钱 —— 每个用户看过的比例从 10% 到 60%，Rasch 和 K=2 的差距怎样变化。
    演示 3：给两个口味相反的用户分别推荐 —— 个性化到底改变了什么。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 02_matrix_factorization.py        # 约 5 秒

只用到 Python 标准库，以及第 1 讲的 toy_world.py、本目录的 factor_models.py。
"""

import random

from factor_models import TasteWorld as World, evaluate, fit_mf, oracle_metrics, predict_prob, split
from toy_world import mean, pad, print_header, sigmoid

L2 = 3.0      # 每个小逻辑回归的 L2 正则化强度（用不同的值试过，3 左右在这个数据上最好；第 5 讲讲怎样系统地选）


# =============================================================================
# 演示 1：几种模型的比较
# =============================================================================
def demo_compare(world, rng):
    print_header("演示 1：只看流行度 → Rasch → K 维因子模型")
    obs = world.sample(rng, 0.6)
    train, test = split(obs, rng)
    print(f"200 个用户 × 40 个回答，每个格子有 60% 的概率被观察到：共 {len(obs)} 条，80% 训练、20% 测试。")
    print("（测试的格子在训练里没出现过，但这个用户和这个回答都在训练里出现过。）\n")
    print(f"  {pad('模型', 22)} {pad('参数个数', 8, 'right')} {pad('对数损失', 8, 'right')} {pad('准确率', 8, 'right')} {pad('AUC', 6, 'right')}")
    rows = [("只看流行度（z_j）", 0, False), ("Rasch（a_i + z_j）", 0, True)]
    rows += [(f"因子模型 K = {k}", k, True) for k in (1, 2, 4, 8)]
    for name, k, ub in rows:
        model = fit_mf(train, world.users, world.items, k, L2, random.Random(7), user_bias=ub)
        n_params = len(world.items) * (1 + k) + len(world.users) * ((1 if ub else 0) + k)
        ll, acc, a = evaluate(model, test)
        print(f"  {pad(name, 22)} {n_params:>8d} {ll:>8.3f} {acc:>8.3f} {a:>6.3f}")
    ll, acc, a = oracle_metrics(world, test)
    print(f"  {pad('上帝模型（真实参数）', 22)} {'—':>8s} {ll:>8.3f} {acc:>8.3f} {a:>6.3f}")
    print("""
观察：
  · 只看流行度：每个回答一个分数，所有人一样。它只能学到“哪些回答普遍好”。
  · Rasch 加上了用户的胃口：知道“这个人什么都爱点有用”，但仍然不知道他喜欢**什么样的**回答。
  · K = 1、2 的因子模型学到了口味：K = 2 和真实世界的维数相同，表现最接近上帝模型。
  · K 再大，参数变多，每个用户只有十来条训练数据，模型开始拟合噪声 —— 靠 L2 正则化压住了一部分，
    但不会再变好。选 K 和正则化强度，要用验证集或交叉验证（第 5 讲）。
  · 这就是推荐系统里“矩阵分解”的思路（Netflix Prize，2006–2009）：评分矩阵 ≈ 用户矩阵 × 选项矩阵ᵀ，
    低秩（K 远小于用户数和选项数）的假设让我们能从别人的数据里“借”信息来填补空格。""")
    return train, test


# =============================================================================
# 演示 2：数据越多，个性化越值钱
# =============================================================================
def demo_data_amount(world, rng):
    print_header("演示 2：每个用户的数据越多，个性化越值钱")
    print(f"  {pad('每格被观察的概率', 16)} {pad('每人约几条训练数据', 18, 'right')} {pad('Rasch 的 AUC', 12, 'right')} "
          f"{pad('K=2 的 AUC', 10, 'right')} {pad('差距', 6, 'right')}")
    for frac in (0.15, 0.3, 0.6, 0.9):
        obs = world.sample(rng, frac)
        train, test = split(obs, rng)
        rasch = fit_mf(train, world.users, world.items, 0, L2, random.Random(7))
        mf = fit_mf(train, world.users, world.items, 2, L2, random.Random(7))
        a_r = evaluate(rasch, test)[2]
        a_m = evaluate(mf, test)[2]
        per_user = len(train) / len(world.users)
        print(f"  {frac:>16.0%} {per_user:>18.1f} {a_r:>12.3f} {a_m:>10.3f} {a_m - a_r:>+6.3f}")
    print("""
观察：
  · 每个用户只有 5 条左右的训练数据时，个性化模型学不到他的口味（两个口味参数加一个胃口，信息太少），
    和 Rasch 打平。这就是推荐系统的“冷启动”问题。
  · 数据一多，个性化的优势迅速扩大：口味维度的信息开始超过噪声。
  · 实践中的折中：新用户先用流行度 / Rasch，数据积累到一定程度再切换到个性化；
    或者用用户的特征（注册信息、所选语言）预测他的初始口味（教材的“上下文”）。""")


# =============================================================================
# 演示 3：给口味相反的两个用户推荐
# =============================================================================
def demo_recommend(world, train):
    print_header("演示 3：给两个口味相反的用户推荐“最可能觉得有用”的回答")
    mf = fit_mf(train, world.users, world.items, 2, L2, random.Random(7))
    pop = fit_mf(train, world.users, world.items, 0, L2, random.Random(7), user_bias=False)
    # 找两个对"详细程度"口味最相反的用户
    by_detail = sorted(world.users, key=lambda i: world.taste[i][0])
    picks = [("最喜欢简洁的用户", by_detail[0]), ("最喜欢详尽的用户", by_detail[-1])]
    top_pop = sorted(world.items, key=lambda j: -pop.z[j])[:3]
    print(f"  只看流行度：对所有人都推荐 {', '.join(top_pop)}"
          f"（它们的详细程度：{', '.join(f'{world.style[j][0]:+.2f}' for j in top_pop)}）")
    for name, i in picks:
        top = sorted(world.items, key=lambda j: -predict_prob(mf, i, j))[:3]
        true_top = sorted(world.items, key=lambda j: -world.logit(i, j))[:3]
        print(f"\n  {name}（{i}，口味 = 详细 {world.taste[i][0]:+.2f}、正式 {world.taste[i][1]:+.2f}）")
        print(f"    K=2 模型推荐：{', '.join(top)}（详细程度 {', '.join(f'{world.style[j][0]:+.2f}' for j in top)}）")
        print(f"    真实最喜欢：  {', '.join(true_top)}（详细程度 {', '.join(f'{world.style[j][0]:+.2f}' for j in true_top)}）")
        print(f"    推荐的 3 个回答在他心里的平均接受概率：K=2 {mean([sigmoid(world.logit(i, j)) for j in top]):.3f}，"
              f"流行度 {mean([sigmoid(world.logit(i, j)) for j in top_pop]):.3f}")

    # 对所有用户平均：推荐的前 3 个回答，在用户心里的平均接受概率
    def avg_accept(rank_fn):
        return mean([mean([sigmoid(world.logit(i, j)) for j in rank_fn(i)[:3]]) for i in world.users])
    by_mf = avg_accept(lambda i: sorted(world.items, key=lambda j: -predict_prob(mf, i, j)))
    by_pop = avg_accept(lambda i: top_pop)
    best = avg_accept(lambda i: sorted(world.items, key=lambda j: -world.logit(i, j)))
    print(f"\n  对全部 200 个用户平均（推荐前 3 个回答的平均接受概率）：流行度 {by_pop:.3f}，K=2 {by_mf:.3f}，"
          f"上帝视角的最优推荐 {best:.3f}")
    print("""
观察：
  · 只看流行度的系统给所有人推荐同样的东西，对口味偏离“平均”的用户不太合适。
  · 个性化模型给喜欢简洁的用户推荐详细程度为负的回答，给喜欢详尽的用户推荐详细程度为正的回答。
    注意我们**从来没有告诉模型**“详细程度”这个维度 —— 它是从“谁和谁口味相似”里自己发现的。
  · 对 RLHF 的启示：今天的奖励模型通常是“只看流行度”的（一个奖励给所有人）。个性化奖励模型
    （给每个用户或每类用户一个口味向量）是一个活跃的研究方向，它要面对的正是演示 2 的冷启动问题，
    以及第 16、17 讲的“该不该迎合每个人”的问题。""")


def main():
    rng = random.Random(2006)        # 2006：Netflix Prize 开始的年份
    world = World(rng)
    train, _ = demo_compare(world, rng)
    demo_data_amount(world, rng)
    demo_recommend(world, train)


if __name__ == "__main__":
    main()
