"""
=============================================================================
第 4 讲 · 代码 3：理想点模型 —— "不要太短，也不要太长"
=============================================================================

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

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
点积模型 U_i · V_j 描述的是"越多越好"或"越少越好"：沿着口味向量的方向，选项走得越远，用户越喜欢。
可是很多偏好不是这样的：回答的详细程度，有人喜欢简洁、有人喜欢详尽，**还有很多人喜欢适中** ——
太短不够用，太长读不完。这种"在某个理想点最喜欢、离得越远越不喜欢"的偏好叫**单峰偏好**，
对应的模型叫**理想点模型**（ideal point model）：

        logit_ij = a_i + z_j − c · ‖u_i − x_j‖²      （u_i：用户的理想点，x_j：选项的位置）

政治学用它分析议员投票（议员和法案都放在"左—右"轴上，议员支持离自己近的法案），
音乐、商品推荐里也很常见。

    演示 1：每个回答的详细程度是已知的特征。三类用户（喜欢简洁 / 适中 / 详尽），每人只用 20 个回答训练。
            比较四种个性化方式：不个性化、单调口味（线性）、自由的二次函数、受约束的理想点模型。
            喜欢适中的用户，单调模型帮不上忙；自由二次数据不够；共享曲率的理想点模型最好。
    演示 2：一个代数事实 —— 平方距离的理想点模型 = 用户偏置 + 选项偏置 + 点积。所以带偏置的
            K 维点积模型**包含**了 K 维理想点模型；理想点模型的价值在于它的**约束**（少一些自由度，学得更快）。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 03_ideal_point.py        # 约 5 秒

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

import math
import random

from factor_models import auc, dot, logreg_newton
from toy_world import bar, mean, pad, print_header, sigmoid

TYPES = [("喜欢简洁", 0.2, 0.35), ("喜欢适中", 0.5, 0.30), ("喜欢详尽", 0.85, 0.35)]   # (名字, 理想点, 占比)
TOLERANCE = 16.0     # c：离理想点每远一点，效用掉多少（越大越"挑剔"）


def make_world(rng, n_users=300, n_items=60):
    """
    造一个理想点世界。

    【返回】dict：users、items、ideal（用户 → 理想点）、kind（用户 → 类型名）、appetite、detail（回答 → 详细程度）、
        quality（回答 → 质量）
    """
    items = [f"r{j:02d}" for j in range(n_items)]
    detail = {j: rng.random() for j in items}
    quality = {j: rng.gauss(0, 0.5) for j in items}
    users, ideal, kind, appetite = [], {}, {}, {}
    for t in range(n_users):
        i = f"u{t:03d}"
        r = rng.random()
        acc = 0.0
        for name, center, share in TYPES:
            acc += share
            if r < acc:
                break
        users.append(i)
        kind[i] = name
        ideal[i] = min(1.0, max(0.0, rng.gauss(center, 0.08)))
        appetite[i] = rng.gauss(1.0, 0.5)
    return dict(users=users, items=items, ideal=ideal, kind=kind, appetite=appetite, detail=detail, quality=quality)


def true_logit(w, i, j):
    """真实的 logit = 胃口 + 1.5·质量 − c·(详细程度 − 理想点)²。"""
    return w["appetite"][i] + 1.5 * w["quality"][j] - TOLERANCE * (w["detail"][j] - w["ideal"][i]) ** 2


# =============================================================================
# 演示 1：四种个性化方式
# =============================================================================
U_GRID = [k / 50 for k in range(51)]          # 理想点的候选值 0, 0.02, ..., 1
C_GRID = [4.0, 8.0, 12.0, 16.0, 20.0, 24.0]   # 共享曲率 c 的候选值


def log_lik(rows, w):
    """一组 (x, offset, y) 在参数 w 下的对数似然。"""
    total = 0.0
    for x, off, y in rows:
        p = sigmoid(dot(w, x) + off)
        total += math.log(max(1e-12, p if y == 1 else 1 - p))
    return total


def fit_ideal_user(rows_xy, offset, c):
    """
    给一个用户拟合"受约束的理想点模型"：logit = a_i + offset_j − c·(x_j − u_i)²，c 是所有人共享的。

    【思路】只有两个参数：胃口 a_i 和理想点 u_i。对 u_i 做网格搜索（0~1 之间 51 个点），
        每个 u_i 下 a_i 是一个一维逻辑回归（牛顿法几步就收敛），取对数似然最大的那一组。
    【参数】rows_xy：list[(x_j, j, y)]；offset：函数 j → 偏移量；c：共享曲率
    【返回】(对数似然, a_i, u_i)
    """
    best = None
    for u in U_GRID:
        rows = [([1.0], offset(j) - c * (x - u) ** 2, y) for x, j, y in rows_xy]
        a = logreg_newton(rows, [0.0], l2=0.1, steps=6)[0]
        ll = log_lik(rows, [a])
        if best is None or ll > best[0]:
            best = (ll, a, u)
    return best


def demo_compare(rng):
    print_header("演示 1：喜欢“适中”的用户 —— 单调模型画不出，理想点模型画得出")
    w = make_world(rng)
    train, test = [], []
    for i in w["users"]:
        seen = rng.sample(w["items"], 35)
        for t, j in enumerate(seen):
            y = 1 if rng.random() < sigmoid(true_logit(w, i, j)) else 0
            (train if t < 20 else test).append((i, j, y))
    by_user = {i: [] for i in w["users"]}
    for i, j, y in train:
        by_user[i].append((w["detail"][j], j, y))

    # 回答的质量当作已知（例如来自专家的打分），作为偏移量；个性化只针对"详细程度"这一个已知特征。
    def offset(j):
        return 1.5 * w["quality"][j]

    # 三种"每个用户一个小逻辑回归"的模型：特征分别是 [1]、[1, x]、[1, x, x²]
    def feats(x, kind):
        return {"none": [1.0], "linear": [1.0, x], "quad": [1.0, x, x * x]}[kind]

    params = {}
    for kind in ("none", "linear", "quad"):
        params[kind] = {i: logreg_newton([(feats(x, kind), offset(j), y) for x, j, y in by_user[i]],
                                         [0.0] * len(feats(0.0, kind)), l2=0.5, steps=20)
                        for i in w["users"]}
    # 理想点：先在训练数据上选共享曲率 c（总对数似然最大），再给每个用户拟合 (a_i, u_i)
    totals = {c: math.fsum(fit_ideal_user(by_user[i], offset, c)[0] for i in w["users"]) for c in C_GRID}
    c_hat = max(C_GRID, key=lambda c: totals[c])
    ideal = {i: fit_ideal_user(by_user[i], offset, c_hat) for i in w["users"]}

    def score(kind, i, j):
        x = w["detail"][j]
        if kind == "ideal":
            _, a, u = ideal[i]
            return a + offset(j) - c_hat * (x - u) ** 2
        return dot(params[kind][i], feats(x, kind)) + offset(j)

    print(f"300 个用户，每人看 35 个回答：20 个用来训练，15 个用来测试。真实的曲率 c = {TOLERANCE:g}；")
    print(f"在训练数据上选出的共享曲率 c = {c_hat:g}。按用户类型分组，报告测试集上的 AUC：\n")
    print(f"  {pad('用户类型', 10)} {pad('不个性化', 9, 'right')} {pad('单调（线性）', 13, 'right')} "
          f"{pad('自由二次', 9, 'right')} {pad('理想点', 7, 'right')} {pad('上帝视角', 9, 'right')}")
    for name, _, _ in TYPES:
        rows = [(i, j, y) for i, j, y in test if w["kind"][i] == name]
        labels = [y for _, _, y in rows]
        res = [auc([score(kind, i, j) for i, j, _ in rows], labels) for kind in ("none", "linear", "quad", "ideal")]
        oracle = auc([true_logit(w, i, j) for i, j, _ in rows], labels)
        print(f"  {pad(name, 10)} {res[0]:>9.3f} {res[1]:>13.3f} {res[2]:>9.3f} {res[3]:>7.3f} {oracle:>9.3f}")

    mid = next(i for i in w["users"] if w["kind"][i] == "喜欢适中")
    print(f"\n  一个喜欢适中的用户（真实理想点 {w['ideal'][mid]:.2f}，理想点模型估计 {ideal[mid][2]:.2f}）："
          "接受概率随详细程度的变化（回答质量取平均水平）")
    print(f"  {pad('详细程度', 8)} {pad('真实', 6, 'right')} {pad('单调模型', 8, 'right')} {pad('理想点模型', 10, 'right')}")
    for x in [0.0, 0.25, 0.5, 0.75, 1.0]:
        t = sigmoid(w["appetite"][mid] - TOLERANCE * (x - w["ideal"][mid]) ** 2)
        lin = sigmoid(dot(params["linear"][mid], [1.0, x]))
        idl = sigmoid(ideal[mid][1] - c_hat * (x - ideal[mid][2]) ** 2)
        print(f"  {x:>8.2f} {t:>6.2f} {lin:>8.2f} {idl:>10.2f}   {bar(t, 20)}")
    print("""
观察：
  · 单调模型给每个用户一条“越详细越好 / 越差”的斜线：对喜欢简洁和喜欢详尽的用户有帮助，
    对喜欢适中的用户几乎没用 —— 一条斜线画不出“中间高、两头低”，最好的斜线接近水平。
  · “自由二次”（每人 3 个自由参数：常数、x、x²）理论上画得出抛物线，但要画出这么陡的抛物线，系数很大
    （x² 的系数约为 −16），每人只有 20 条数据，再加上正则化往 0 拉，学不出来。
  · 理想点模型让**所有人共享**曲率 c，每人只学胃口和理想点两个数。约束更多、自由参数更少，
    20 条数据就够了，三类用户都接近上帝视角 —— 喜欢适中的用户提升最大。
    这就是“利用几何结构，学得更快”（教材对理想点模型的评价）；代价是：真实偏好如果不是单峰的，约束就错了。""")


# =============================================================================
# 演示 2：理想点 = 偏置 + 点积
# =============================================================================
def demo_identity(rng):
    print_header("演示 2：平方距离的理想点模型 = 用户偏置 + 选项偏置 + 点积")
    print("""−‖u − x‖² = −‖u‖² − ‖x‖² + 2·u·x。所以
    a_i + z_j − ‖u_i − x_j‖²  =  (a_i − ‖u_i‖²)  +  (z_j − ‖x_j‖²)  +  (√2·u_i)·(√2·x_j)
                                 └ 新的用户偏置 ┘   └ 新的选项偏置 ┘   └ 点积 ┘
随便抽几组二维的 u、x 验证一下：""")
    for _ in range(3):
        u = [rng.gauss(0, 1), rng.gauss(0, 1)]
        x = [rng.gauss(0, 1), rng.gauss(0, 1)]
        a, z = rng.gauss(0, 1), rng.gauss(0, 1)
        left = a + z - sum((ui - xi) ** 2 for ui, xi in zip(u, x))
        right = (a - dot(u, u)) + (z - dot(x, x)) + dot([math.sqrt(2) * t for t in u], [math.sqrt(2) * t for t in x])
        print(f"    理想点形式 {left:+.6f}    偏置 + 点积形式 {right:+.6f}")
    print("""
观察：
  · 两种写法完全相等。所以**带偏置**的 K 维点积因子模型（代码 02 的 fit_mf）已经包含了 K 维的平方距离理想点模型：
    选项偏置里的 −‖x_j‖² 让“离中心远的选项”整体吃亏，点积部分再决定每个用户偏向哪边 —— 合起来就是单峰的。
  · 那为什么还要理想点模型？因为它是一个**受约束**的版本：选项偏置必须等于“吸引力 − ‖位置‖²”，
    用户偏置必须等于“胃口 − ‖理想点‖²”。约束越多，自由参数越少，数据少时学得越快、也更好解释
    （位置和理想点放在同一张图上，一眼就能看出谁离谁近）。代价是：如果真实的偏好不是单峰的，约束就错了。
  · 用绝对距离 −‖u − x‖（而不是平方）时，这个恒等式不成立，理想点模型就真的是另一类模型了（Jamieson & Nowak 2011
    研究了用这种几何结构加速主动学习）。
  · 政治学的 NOMINATE（Poole & Rosenthal）把每位美国国会议员和每次投票放在一两维的空间里，
    第一维几乎就是“左—右”。在偏好学习里，“回答的风格”往往也有这样的低维几何。""")


def main():
    rng = random.Random(1985)        # 1985：Poole & Rosenthal 发表 NOMINATE 的年份
    demo_compare(rng)
    demo_identity(rng)


if __name__ == "__main__":
    main()
