"""
=============================================================================
第 4 讲 · 代码 6：Community Notes —— 用因子模型把"立场"和"质量"分开
=============================================================================

对应讲义：lectures/lecture04_factor_models.md 第 7 节（第 17 讲还会从"聚合"的角度再讨论）

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
X（原 Twitter）的 Community Notes（社区笔记）让用户给帖子写"补充说明"，再请其他用户评价这条说明
"有没有用"。问题是：评价者有政治立场，一条偏向某一派的说明，会被那一派的人大量点"有用"。
如果按"有用率"排序，人多的那一派喜欢的说明就会胜出 —— 不管它写得好不好。

Community Notes 公开的算法用的正是本讲的因子模型（Wojcik 等 2022）：

        评价_un ≈ μ + i_u + i_n + f_u · f_n

    i_u：评价者的"宽松程度"（胃口）      f_u：评价者的立场（1 维因子）
    i_n：说明的"截距" —— 扣除立场因素之后，大家都认可的程度     f_n：说明的立场倾向
一条说明只有在**截距** i_n 足够高时才会被展示：它必须得到"立场不同的人"的共同认可，这叫"桥接"（bridging）。

    演示 1：按"有用率"排序 vs 按因子模型的截距排序，哪个更接近说明的真实质量？
            截距有一个"零点"问题：把哪里当作中立的立场，数据决定不了。
    演示 2：学到的 1 维因子是不是就是评价者的立场？

【简化】真实算法用平方损失、对截距的正则化比对因子更强（据公开的说明，约 5 倍）、还有很多额外的规则；
这里用本讲的逻辑斯蒂因子模型（fit_mf，K = 1），并用"把零点移到两派中点"这个透明的做法说明核心思想。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 06_community_notes.py        # 约 1 秒

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

import random

from factor_models import fit_mf
from toy_world import kendall_tau, mean, pad, print_header, sigmoid


def make_world(rng, n_raters=600, n_notes=40, per_rater=15):
    """
    造一个评价者 × 说明的世界。

    【思路】
        · 评价者：70% 是"甲派"（立场 ≈ −1），30% 是"乙派"（立场 ≈ +1），各有自己的宽松程度。
        · 说明：16 条偏甲派、16 条偏乙派、8 条中立；每条有一个真实的质量（和立场无关）。
        · P(评价"有用") = σ(宽松程度 + 质量 + 2 × 评价者立场 × 说明立场)：立场相同的人更容易说"有用"。
        · 每个评价者只评价 15 条随机的说明（矩阵大部分是空的）。
    【返回】(评价列表 [(评价者, 说明, y)], 评价者列表, 说明列表, 真实信息 dict)
    """
    raters, side, lean, lenient = [], {}, {}, {}
    for t in range(n_raters):
        r = f"评{t:03d}"
        raters.append(r)
        side[r] = "甲派" if rng.random() < 0.7 else "乙派"
        lean[r] = rng.gauss(-1.0 if side[r] == "甲派" else 1.0, 0.3)
        lenient[r] = rng.gauss(0.0, 0.3)
    notes, slant, quality, kind = [], {}, {}, {}
    for t in range(n_notes):
        n = f"注{t:02d}"
        notes.append(n)
        if t < 16:
            kind[n], slant[n] = "偏甲派", rng.gauss(-1.2, 0.3)
        elif t < 32:
            kind[n], slant[n] = "偏乙派", rng.gauss(1.2, 0.3)
        else:
            kind[n], slant[n] = "中立", rng.gauss(0.0, 0.2)
        quality[n] = rng.gauss(0.0, 0.5)
    ratings = []
    for r in raters:
        for n in rng.sample(notes, per_rater):
            p = sigmoid(lenient[r] + quality[n] + 2.0 * lean[r] * slant[n])
            ratings.append((r, n, 1 if rng.random() < p else 0))
    truth = dict(side=side, lean=lean, slant=slant, quality=quality, kind=kind)
    return ratings, raters, notes, truth


def correlation(xs, ys):
    """皮尔逊相关系数。"""
    mx, my = mean(xs), mean(ys)
    sxy = sum((x - mx) * (y - my) for x, y in zip(xs, ys))
    return sxy / (sum((x - mx) ** 2 for x in xs) * sum((y - my) ** 2 for y in ys)) ** 0.5


def two_means(xs, iters=50):
    """
    一维的"两类聚类"（k-means，k = 2）：把一组数分成两堆，返回两堆的中心。

    【思路】先把最小值、最大值当作两个中心；每个数归到离它近的中心；再把中心更新成各堆的平均值；重复。
    【参数】xs：数的列表；iters：迭代次数
    【返回】(较小的中心, 较大的中心)
    【边界】数据只有一堆时，两个中心会挤在一起，"中点"就没有意义了。
    """
    c1, c2 = min(xs), max(xs)
    for _ in range(iters):
        g1 = [x for x in xs if abs(x - c1) <= abs(x - c2)]
        g2 = [x for x in xs if abs(x - c1) > abs(x - c2)]
        if not g1 or not g2:
            break
        c1, c2 = mean(g1), mean(g2)
    return c1, c2


def demo_bridging(rng):
    print_header("演示 1：按“有用率”排序 vs 按因子模型的“截距”排序")
    ratings, raters, notes, truth = make_world(rng)
    q = truth["quality"]
    rate = {n: mean([y for _, m, y in ratings if m == n]) for n in notes}
    model = fit_mf(ratings, raters, notes, k=1, l2=1.0, rng=random.Random(3))
    # 截距的"零点"问题：评价 = a_u + z_n + f_u·g_n。把评价者的因子整体平移 c（f_u → f_u − c），
    # 同时把截距改成 z_n + c·g_n，预测完全不变。所以"截距"取决于把哪里当作立场的零点 —— 数据决定不了。
    #   · 默认（正则化的结果）：零点 ≈ 所有评价者的平均立场 —— 人多的一派把零点拉向自己。
    #   · 另一种选择：零点 = 两派的中点（先用两类聚类找到两派的中心，再取中点）—— 两派各占一半的分量。
    f_all = [model.u[r][0] for r in raters]
    c1, c2 = two_means(f_all)
    c0 = (c1 + c2) / 2
    centered = {n: model.z[n] + c0 * model.v[n][0] for n in notes}
    print(f"600 个评价者（70% 甲派、30% 乙派）、40 条说明，共 {len(ratings)} 次评价。")
    print(f"评价者因子的平均值 {mean(f_all):+.2f}；两类聚类找到的两派中心 {c1:+.2f} 和 {c2:+.2f}，中点 {c0:+.2f}。")
    print("和说明的**真实质量**比较排名一致性（肯德尔 τ），并看排名前 8 的说明都是哪一类：\n")
    print(f"  {pad('排序方法', 30)} {pad('τ', 6, 'right')}   前 8 名：偏甲派 / 偏乙派 / 中立    前 8 名的平均真实质量")
    for name, score in [("有用率", rate), ("截距（零点 = 全体平均立场）", model.z),
                        ("截距（零点 = 两派的中点）", centered), ("（真实质量）", q)]:
        top = sorted(notes, key=lambda n: -score[n])[:8]
        counts = [sum(1 for n in top if truth["kind"][n] == k) for k in ("偏甲派", "偏乙派", "中立")]
        print(f"  {pad(name, 30)} {kendall_tau(score, q, notes):>6.2f}   {counts[0]:>9d} / {counts[1]:>6d} / {counts[2]:>4d}"
              f"    {mean([q[n] for n in top]):>+8.2f}")
    print("""
观察：
  · 按有用率排序，前 8 名被“偏甲派”的说明占据 —— 甲派人多，偏向甲派的说明自然收获更多“有用”，
    哪怕它的真实质量一般；乙派喜欢的说明一条也进不去。
  · 因子模型把每次评价拆成“立场的部分”（f_u · f_n）和“截距”。可是截距有一个**零点问题**：
    把所有评价者的立场整体平移一下、同时调整截距，预测完全不变（又是代码 04 的不可识别性）。
    默认的正则化把零点放在“全体评价者的平均立场”—— 人多的甲派把零点拉向了自己，截距里仍然混着
    “甲派人多”的优势，所以按截距排序只比有用率好一点。
  · 把零点改成“两派的中点”（两派各占一半的分量），截距才真正衡量“立场之外的认可”：和真实质量的一致性
    大幅提高，前 8 名里三类说明都有。
  · 零点放在哪里，是数据**决定不了**的规范性选择：按人头平均（多数派优先），还是按派别平均（每派平等）？
    Community Notes 公开的算法用不对称的正则化（对截距罚得比因子更重）隐式地做了这个选择，并要求一条说明
    必须得到不同立场评价者的共同认可才会被展示 —— 这就是“桥接”。第 16、17 讲会把它放进“怎样聚合
    很多人的偏好”的框架里讨论：没有中立的聚合规则，每种规则都编码了一种价值判断。""")
    return model, notes, raters, truth


def demo_factor_meaning(model, notes, raters, truth):
    print_header("演示 2：学到的 1 维因子，是不是就是立场？")
    c_note = correlation([model.v[n][0] for n in notes], [truth["slant"][n] for n in notes])
    c_rater = correlation([model.u[r][0] for r in raters], [truth["lean"][r] for r in raters])
    print(f"  说明的因子 f_n 和真实立场倾向的相关：{c_note:+.3f}")
    print(f"  评价者的因子 f_u 和真实立场的相关：  {c_rater:+.3f}")
    sides = {}
    for r in raters:
        sides.setdefault(truth["side"][r], []).append(model.u[r][0])
    print("  两派评价者的因子平均值：" + "，".join(f"{s} {mean(v):+.2f}" for s, v in sorted(sides.items())))
    print("""
观察：
  · 模型**从来没有被告知**谁是哪一派、哪条说明偏向谁，但它学到的 1 维因子和立场高度相关（正负号是任意的：
    整体乘以 −1 不改变 f_u · f_n，这就是代码 04 的“翻转”）。两派评价者被清楚地分到了因子的两头。
  · 这说明在这类数据里，“评价者之间最大的分歧”恰好就是立场 —— 因子模型自动找到了它。
    真实的 Community Notes 数据里，第一个因子也大体对应政治上的左右（据公开的研究报告）。
  · 局限：如果分歧有不止一个维度（例如立场之外还有“专业 vs 业余”），1 维因子就不够了；
    如果某条说明只有一派的人评价过，截距就无从估计 —— 这正是真实系统需要“足够多、足够多样”的评价者的原因。""")


def main():
    rng = random.Random(2021)        # 2021：Birdwatch（Community Notes 的前身）上线的年份
    model, notes, raters, truth = demo_bridging(rng)
    demo_factor_meaning(model, notes, raters, truth)


if __name__ == "__main__":
    main()
